tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/controller/AppAuthController.java
@@ -1,9 +1,13 @@ package com.tcm.training.admin.app.auth.controller; import com.tcm.training.admin.app.auth.dto.AppSmsCodeSendRequest; import com.tcm.training.admin.app.auth.dto.AppPasswordLoginRequest; import com.tcm.training.admin.app.auth.dto.AppPasswordResetRequest; import com.tcm.training.admin.app.auth.dto.AppPasswordResetVerifyRequest; import com.tcm.training.admin.app.auth.dto.AppSmsLoginRequest; import com.tcm.training.admin.app.auth.service.AppAuthService; import com.tcm.training.admin.app.auth.vo.AppLoginResponse; import com.tcm.training.admin.app.auth.vo.AppPasswordResetVerifyResponse; import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse; import com.tcm.training.admin.common.ApiResponse; import org.springframework.validation.annotation.Validated; @@ -46,4 +50,37 @@ public ApiResponse<AppLoginResponse> smsLogin(@Valid @RequestBody AppSmsLoginRequest request) { return ApiResponse.ok(appAuthService.smsLogin(request)); } /** * 使用手机号密码完成 APP 登录。 */ @PostMapping("/password-login") public ApiResponse<AppLoginResponse> passwordLogin(@Valid @RequestBody AppPasswordLoginRequest request) { return ApiResponse.ok(appAuthService.passwordLogin(request)); } /** * 发送 APP 忘记密码验证码。 */ @PostMapping("/password-reset/code") public ApiResponse<AppSmsCodeSendResponse> sendPasswordResetCode(@Valid @RequestBody AppSmsCodeSendRequest request) { return ApiResponse.ok(appAuthService.sendPasswordResetCode(request)); } /** * 校验 APP 忘记密码验证码并生成一次性重置凭证。 */ @PostMapping("/password-reset/verify") public ApiResponse<AppPasswordResetVerifyResponse> verifyPasswordResetCode(@Valid @RequestBody AppPasswordResetVerifyRequest request) { return ApiResponse.ok(appAuthService.verifyPasswordResetCode(request)); } /** * 使用重置凭证更新 APP 用户密码。 */ @PostMapping("/password-reset") public ApiResponse<Void> resetPassword(@Valid @RequestBody AppPasswordResetRequest request) { appAuthService.resetPassword(request); return ApiResponse.ok(null); } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppPasswordLoginRequest.java
New file @@ -0,0 +1,22 @@ package com.tcm.training.admin.app.auth.dto; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.validation.constraints.NotBlank; /** * APP 手机号密码登录请求。 */ @Getter @Setter @NoArgsConstructor public class AppPasswordLoginRequest { @NotBlank(message = "请输入正确的手机号") private String phone; @NotBlank(message = "请输入密码") private String password; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppPasswordResetRequest.java
New file @@ -0,0 +1,25 @@ package com.tcm.training.admin.app.auth.dto; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.validation.constraints.NotBlank; /** * APP 忘记密码重置请求。 */ @Getter @Setter @NoArgsConstructor public class AppPasswordResetRequest { @NotBlank(message = "重置凭证已失效,请重新验证") private String resetTicket; @NotBlank(message = "请输入新密码") private String password; @NotBlank(message = "请再次输入密码") private String confirmPassword; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppPasswordResetVerifyRequest.java
New file @@ -0,0 +1,22 @@ package com.tcm.training.admin.app.auth.dto; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.validation.constraints.NotBlank; /** * APP 忘记密码验证码校验请求。 */ @Getter @Setter @NoArgsConstructor public class AppPasswordResetVerifyRequest { @NotBlank(message = "请输入正确的手机号") private String phone; @NotBlank(message = "请输入验证码") private String code; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java
@@ -1,36 +1,56 @@ package com.tcm.training.admin.app.auth.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.tcm.training.admin.app.auth.dto.AppPasswordLoginRequest; import com.tcm.training.admin.app.auth.dto.AppPasswordResetRequest; import com.tcm.training.admin.app.auth.dto.AppPasswordResetVerifyRequest; import com.tcm.training.admin.app.auth.dto.AppSmsCodeSendRequest; import com.tcm.training.admin.app.auth.dto.AppSmsLoginRequest; import com.tcm.training.admin.app.auth.support.StpAppUserUtil; import com.tcm.training.admin.app.auth.vo.AppLoginResponse; import com.tcm.training.admin.app.auth.vo.AppLoginUserVO; import com.tcm.training.admin.app.auth.vo.AppPasswordResetVerifyResponse; import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse; import com.tcm.training.admin.member.entity.AppUser; import com.tcm.training.admin.member.mapper.UserMapper; import cn.dev33.satoken.stp.SaTokenInfo; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; /** * APP 短信验证码登录服务。 * APP 登录认证服务,负责短信登录、密码登录与忘记密码流程。 */ @Service public class AppAuthService { private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$"); private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?![0-9]+$)(?![a-zA-Z]+$)(?![^0-9a-zA-Z]+$).{8,32}$"); private static final String PASSWORD_RESET_TICKET_CACHE_PREFIX = "app:password-reset:ticket:"; private static final long PASSWORD_RESET_TICKET_EXPIRE_MINUTES = 10L; private final UserMapper userMapper; private final AppSmsCodeService appSmsCodeService; private final BCryptPasswordEncoder passwordEncoder; private final AppAuthSessionService appAuthSessionService; private final StringRedisTemplate stringRedisTemplate; /** * 注入 APP 用户查询能力与短信验证码服务。 */ public AppAuthService(UserMapper userMapper, AppSmsCodeService appSmsCodeService) { public AppAuthService(UserMapper userMapper, AppSmsCodeService appSmsCodeService, BCryptPasswordEncoder passwordEncoder, AppAuthSessionService appAuthSessionService, StringRedisTemplate stringRedisTemplate) { this.userMapper = userMapper; this.appSmsCodeService = appSmsCodeService; this.passwordEncoder = passwordEncoder; this.appAuthSessionService = appAuthSessionService; this.stringRedisTemplate = stringRedisTemplate; } /** @@ -43,15 +63,72 @@ } /** * 为已注册 APP 用户生成忘记密码验证码。 */ public AppSmsCodeSendResponse sendPasswordResetCode(AppSmsCodeSendRequest request) { String phone = normalizePhone(request.getPhone()); requireEnabledUserByPhone(phone); return appSmsCodeService.createPasswordResetCode(phone); } /** * 使用短信验证码完成 APP 用户登录。 */ public AppLoginResponse smsLogin(AppSmsLoginRequest request) { String phone = normalizePhone(request.getPhone()); AppUser user = requireEnabledUserByPhone(phone); appSmsCodeService.validateLoginCode(phone, normalizeCode(request.getCode())); return createLoginResponse(user); } StpAppUserUtil.login(user.getId()); SaTokenInfo tokenInfo = StpAppUserUtil.getTokenInfo(); /** * 使用手机号密码完成 APP 用户登录。 */ public AppLoginResponse passwordLogin(AppPasswordLoginRequest request) { String phone = normalizePhone(request.getPhone()); String password = normalizeLoginPassword(request.getPassword()); AppUser user = requireEnabledUserByPhone(phone); if (!passwordEncoder.matches(password, user.getPassword())) { throw new IllegalArgumentException("账号或密码错误"); } return createLoginResponse(user); } /** * 校验忘记密码验证码并生成一次性重置凭证。 */ public AppPasswordResetVerifyResponse verifyPasswordResetCode(AppPasswordResetVerifyRequest request) { String phone = normalizePhone(request.getPhone()); requireEnabledUserByPhone(phone); appSmsCodeService.validatePasswordResetCode(phone, normalizeCode(request.getCode())); String resetTicket = createResetTicket(); cacheResetTicket(resetTicket, phone); return new AppPasswordResetVerifyResponse(resetTicket); } /** * 使用一次性重置凭证更新 APP 用户密码。 */ public void resetPassword(AppPasswordResetRequest request) { String resetTicket = normalizeResetTicket(request.getResetTicket()); String password = normalizeNewPassword(request.getPassword()); String confirmPassword = normalizeConfirmPassword(request.getConfirmPassword()); validatePasswordConfirmation(password, confirmPassword); String phone = loadPhoneByResetTicket(resetTicket); AppUser user = requireEnabledUserByPhone(phone); user.setPassword(passwordEncoder.encode(password)); userMapper.updateById(user); stringRedisTemplate.delete(buildResetTicketKey(resetTicket)); } /** * 为成功登录的 APP 用户建立登录态并拼装统一响应。 */ private AppLoginResponse createLoginResponse(AppUser user) { appAuthSessionService.login(user.getId()); SaTokenInfo tokenInfo = appAuthSessionService.getTokenInfo(); return new AppLoginResponse( tokenInfo.getTokenValue(), "Bearer", @@ -77,9 +154,9 @@ } /** * 按手机号查询并校验 APP 用户状态。 * 按手机号查询 APP 用户,不存在时抛出统一业务异常。 */ private AppUser requireEnabledUserByPhone(String phone) { private AppUser requireExistingUserByPhone(String phone) { AppUser user = userMapper.selectOne(new LambdaQueryWrapper<AppUser>() .eq(AppUser::getPhone, phone) .eq(AppUser::getDeleted, 0) @@ -87,6 +164,14 @@ if (user == null) { throw new IllegalArgumentException("账号不存在,请先注册"); } return user; } /** * 按手机号查询并校验 APP 用户状态。 */ private AppUser requireEnabledUserByPhone(String phone) { AppUser user = requireExistingUserByPhone(phone); if (user.getStatus() == null || user.getStatus() != 1) { throw new IllegalArgumentException("账号已被禁用"); } @@ -116,6 +201,102 @@ } /** * 归一化密码登录场景下的密码输入,仅要求非空。 */ private String normalizeLoginPassword(String password) { String normalized = trimToNull(password); if (normalized == null) { throw new IllegalArgumentException("请输入密码"); } return normalized; } /** * 归一化并校验忘记密码场景下的新密码格式。 */ private String normalizeNewPassword(String password) { String normalized = trimToNull(password); if (normalized == null) { throw new IllegalArgumentException("请输入新密码"); } if (!PASSWORD_PATTERN.matcher(normalized).matches()) { throw new IllegalArgumentException("密码需为8-32位,且包含数字、字母、符号至少2种"); } return normalized; } /** * 归一化并校验确认密码输入。 */ private String normalizeConfirmPassword(String confirmPassword) { String normalized = trimToNull(confirmPassword); if (normalized == null) { throw new IllegalArgumentException("请再次输入密码"); } if (!PASSWORD_PATTERN.matcher(normalized).matches()) { throw new IllegalArgumentException("密码需为8-32位,且包含数字、字母、符号至少2种"); } return normalized; } /** * 归一化并校验一次性重置凭证。 */ private String normalizeResetTicket(String resetTicket) { String normalized = trimToNull(resetTicket); if (normalized == null) { throw new IllegalArgumentException("重置凭证已失效,请重新验证"); } return normalized; } /** * 校验两次输入的新密码保持一致。 */ private void validatePasswordConfirmation(String password, String confirmPassword) { if (!password.equals(confirmPassword)) { throw new IllegalArgumentException("两次密码输入不一致"); } } /** * 生成忘记密码场景使用的一次性重置凭证。 */ private String createResetTicket() { return UUID.randomUUID().toString().replace("-", ""); } /** * 将重置凭证与手机号绑定写入 Redis,供下一步更新密码使用。 */ private void cacheResetTicket(String resetTicket, String phone) { stringRedisTemplate.opsForValue().set( buildResetTicketKey(resetTicket), phone, PASSWORD_RESET_TICKET_EXPIRE_MINUTES, TimeUnit.MINUTES ); } /** * 从 Redis 读取并校验一次性重置凭证对应的手机号。 */ private String loadPhoneByResetTicket(String resetTicket) { String phone = stringRedisTemplate.opsForValue().get(buildResetTicketKey(resetTicket)); if (phone == null || phone.trim().isEmpty()) { throw new IllegalArgumentException("重置凭证已失效,请重新验证"); } return phone; } /** * 构造忘记密码重置凭证的 Redis key。 */ private String buildResetTicketKey(String resetTicket) { return PASSWORD_RESET_TICKET_CACHE_PREFIX + resetTicket; } /** * 优先使用昵称,缺失时回退到手机号作为展示名称。 */ private String resolveNickname(AppUser user) { tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthSessionService.java
New file @@ -0,0 +1,26 @@ package com.tcm.training.admin.app.auth.service; import com.tcm.training.admin.app.auth.support.StpAppUserUtil; import cn.dev33.satoken.stp.SaTokenInfo; import org.springframework.stereotype.Service; /** * APP 登录态门面,隔离 Sa-Token 静态调用,便于认证服务测试。 */ @Service public class AppAuthSessionService { /** * 为指定 APP 用户建立登录态。 */ public void login(Long userId) { StpAppUserUtil.login(userId); } /** * 获取当前 APP 登录态的 token 信息。 */ public SaTokenInfo getTokenInfo() { return StpAppUserUtil.getTokenInfo(); } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java
@@ -16,6 +16,7 @@ private static final String LOGIN_CODE_CACHE_PREFIX = "app:sms:login:"; private static final String REGISTER_CODE_CACHE_PREFIX = "app:sms:register:"; private static final String PASSWORD_RESET_CODE_CACHE_PREFIX = "app:sms:password-reset:"; private final StringRedisTemplate stringRedisTemplate; private final AppSmsSender appSmsSender; @@ -36,14 +37,21 @@ * 生成并发送登录验证码,同时写入缓存。 */ public AppSmsCodeSendResponse createLoginCode(String phone) { return createCode(phone, LOGIN_CODE_CACHE_PREFIX, true); return createCode(phone, LOGIN_CODE_CACHE_PREFIX, "login"); } /** * 生成并发送注册验证码,同时写入缓存。 */ public AppSmsCodeSendResponse createRegisterCode(String phone) { return createCode(phone, REGISTER_CODE_CACHE_PREFIX, false); return createCode(phone, REGISTER_CODE_CACHE_PREFIX, "register"); } /** * 生成并发送忘记密码验证码,同时写入缓存。 */ public AppSmsCodeSendResponse createPasswordResetCode(String phone) { return createCode(phone, PASSWORD_RESET_CODE_CACHE_PREFIX, "password-reset"); } /** @@ -61,9 +69,16 @@ } /** * 校验忘记密码验证码,成功后销毁缓存中的一次性验证码。 */ public void validatePasswordResetCode(String phone, String code) { validateCode(phone, code, PASSWORD_RESET_CODE_CACHE_PREFIX); } /** * 统一生成指定场景的验证码并写入缓存。 */ private AppSmsCodeSendResponse createCode(String phone, String cachePrefix, boolean loginScene) { private AppSmsCodeSendResponse createCode(String phone, String cachePrefix, String scene) { String code = generateCode(); stringRedisTemplate.opsForValue().set( buildCacheKey(cachePrefix, phone), @@ -71,10 +86,12 @@ appSmsAuthProperties.getExpireSeconds(), TimeUnit.SECONDS ); if (loginScene) { if ("login".equals(scene)) { appSmsSender.sendLoginCode(phone, code); } else { } else if ("register".equals(scene)) { appSmsSender.sendRegisterCode(phone, code); } else { appSmsSender.sendPasswordResetCode(phone, code); } return new AppSmsCodeSendResponse(appSmsAuthProperties.getExpireSeconds()); } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsSender.java
@@ -14,4 +14,9 @@ * 发送 APP 注册验证码。 */ void sendRegisterCode(String phone, String code); /** * 发送 APP 忘记密码验证码。 */ void sendPasswordResetCode(String phone, String code); } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/LoggingAppSmsSender.java
@@ -28,4 +28,12 @@ public void sendRegisterCode(String phone, String code) { log.info("APP注册验证码已生成,手机号={}, code={}", PhoneMasker.mask(phone), code); } /** * 记录忘记密码验证码日志,便于开发环境前后端联调。 */ @Override public void sendPasswordResetCode(String phone, String code) { log.info("APP重置密码验证码已生成,手机号={}, code={}", PhoneMasker.mask(phone), code); } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/vo/AppPasswordResetVerifyResponse.java
New file @@ -0,0 +1,23 @@ package com.tcm.training.admin.app.auth.vo; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; /** * APP 忘记密码验证码校验成功响应。 */ @Getter @Setter @NoArgsConstructor public class AppPasswordResetVerifyResponse { private String resetTicket; /** * 使用重置凭证构造响应。 */ public AppPasswordResetVerifyResponse(String resetTicket) { this.resetTicket = resetTicket; } } tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppAuthServiceTest.java
New file @@ -0,0 +1,228 @@ package com.tcm.training.admin.app.auth.service; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.fasterxml.jackson.databind.ObjectMapper; import com.tcm.training.admin.app.auth.dto.AppPasswordLoginRequest; import com.tcm.training.admin.app.auth.dto.AppPasswordResetRequest; import com.tcm.training.admin.app.auth.dto.AppPasswordResetVerifyRequest; import com.tcm.training.admin.app.auth.dto.AppSmsCodeSendRequest; import com.tcm.training.admin.app.auth.vo.AppLoginResponse; import com.tcm.training.admin.app.auth.vo.AppPasswordResetVerifyResponse; import com.tcm.training.admin.app.auth.vo.AppSmsCodeSendResponse; import com.tcm.training.admin.member.entity.AppUser; import com.tcm.training.admin.member.mapper.UserMapper; import cn.dev33.satoken.stp.SaTokenInfo; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * APP 认证服务测试。 */ @ExtendWith(MockitoExtension.class) class AppAuthServiceTest { @BeforeAll static void initLambdaCache() { MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); TableInfoHelper.initTableInfo(assistant, AppUser.class); } @Mock private UserMapper userMapper; @Mock private AppSmsCodeService appSmsCodeService; @Mock private BCryptPasswordEncoder passwordEncoder; @Mock private AppAuthSessionService appAuthSessionService; @Mock private StringRedisTemplate stringRedisTemplate; @Mock private ValueOperations<String, String> valueOperations; private AppAuthService appAuthService; @Captor private ArgumentCaptor<AppUser> appUserCaptor; /** * 每个用例前重新构造被测认证服务。 */ @BeforeEach void setUp() { appAuthService = new AppAuthService( userMapper, appSmsCodeService, passwordEncoder, appAuthSessionService, stringRedisTemplate ); } /** * 密码登录成功时应返回统一登录响应并建立 APP 登录态。 */ @Test void passwordLoginShouldReturnTokenWhenPasswordMatches() { AppPasswordLoginRequest request = new AppPasswordLoginRequest(); request.setPhone("13800138000"); request.setPassword("abc123!!"); AppUser user = buildEnabledUser("13800138000", "ENCODED"); SaTokenInfo tokenInfo = new SaTokenInfo(); tokenInfo.setTokenValue("token-value"); tokenInfo.setTokenTimeout(7200L); when(userMapper.selectOne(any())).thenReturn(user); when(passwordEncoder.matches("abc123!!", "ENCODED")).thenReturn(true); when(appAuthSessionService.getTokenInfo()).thenReturn(tokenInfo); AppLoginResponse response = appAuthService.passwordLogin(request); verify(appAuthSessionService).login(1L); assertEquals("token-value", response.getToken()); assertEquals("Bearer", response.getTokenType()); assertEquals(7200L, response.getExpiresIn()); assertEquals(Long.valueOf(1L), response.getUser().getId()); assertEquals("13800138000", response.getUser().getPhone()); } /** * 密码错误时应返回统一错误文案。 */ @Test void passwordLoginShouldFailWhenPasswordMismatch() { AppPasswordLoginRequest request = new AppPasswordLoginRequest(); request.setPhone("13800138000"); request.setPassword("bad-password"); AppUser user = buildEnabledUser("13800138000", "ENCODED"); when(userMapper.selectOne(any())).thenReturn(user); when(passwordEncoder.matches("bad-password", "ENCODED")).thenReturn(false); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> appAuthService.passwordLogin(request)); assertEquals("账号或密码错误", exception.getMessage()); } /** * 忘记密码发码仅允许已启用用户发送。 */ @Test void sendPasswordResetCodeShouldReturnExpireSeconds() { AppSmsCodeSendRequest request = new AppSmsCodeSendRequest(); request.setPhone("13800138001"); when(userMapper.selectOne(any())).thenReturn(buildEnabledUser("13800138001", "ENCODED")); when(appSmsCodeService.createPasswordResetCode("13800138001")).thenReturn(new AppSmsCodeSendResponse(300)); AppSmsCodeSendResponse response = appAuthService.sendPasswordResetCode(request); assertEquals(300, response.getExpireInSeconds()); } /** * 忘记密码验证码校验成功后应签发一次性重置凭证并写入缓存。 */ @Test void verifyPasswordResetCodeShouldCacheTicket() { AppPasswordResetVerifyRequest request = new AppPasswordResetVerifyRequest(); request.setPhone("13800138002"); request.setCode("666666"); when(userMapper.selectOne(any())).thenReturn(buildEnabledUser("13800138002", "ENCODED")); when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); doNothing().when(appSmsCodeService).validatePasswordResetCode("13800138002", "666666"); AppPasswordResetVerifyResponse response = appAuthService.verifyPasswordResetCode(request); assertNotNull(response.getResetTicket()); verify(valueOperations).set(anyString(), anyString(), anyLong(), any(TimeUnit.class)); } /** * 使用有效重置凭证时应更新数据库中的密码并销毁凭证。 */ @Test void resetPasswordShouldUpdatePasswordAndDeleteTicket() { AppPasswordResetRequest request = new AppPasswordResetRequest(); request.setResetTicket("ticket-1"); request.setPassword("abc123!!"); request.setConfirmPassword("abc123!!"); AppUser user = buildEnabledUser("13800138003", "OLD"); when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); when(valueOperations.get(anyString())).thenReturn("13800138003"); when(userMapper.selectOne(any())).thenReturn(user); when(passwordEncoder.encode("abc123!!")).thenReturn("NEW-ENCODED"); appAuthService.resetPassword(request); verify(userMapper).updateById(appUserCaptor.capture()); verify(stringRedisTemplate).delete(anyString()); assertEquals("NEW-ENCODED", appUserCaptor.getValue().getPassword()); } /** * 重置凭证失效时应拒绝更新密码。 */ @Test void resetPasswordShouldFailWhenTicketExpired() { AppPasswordResetRequest request = new AppPasswordResetRequest(); request.setResetTicket("expired-ticket"); request.setPassword("abc123!!"); request.setConfirmPassword("abc123!!"); when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); when(valueOperations.get(anyString())).thenReturn(null); IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> appAuthService.resetPassword(request)); assertEquals("重置凭证已失效,请重新验证", exception.getMessage()); } /** * 统一构造启用中的 APP 用户测试桩。 */ private AppUser buildEnabledUser(String phone, String password) { AppUser user = new AppUser(); user.setId(1L); user.setPhone(phone); user.setPassword(password); user.setNickname("测试用户"); user.setStatus(1); user.setType(0); return user; } } traditionalApp/pages/forgotPassword/forgotPassword.vue
@@ -48,105 +48,240 @@ </template> <script setup lang="ts"> import { ref, computed } from "vue"; import { computed, ref } from 'vue' import { resetPassword, sendPasswordResetCode, verifyPasswordResetCode } from './service.js' const phone = ref(''); const step = ref(1); // 1: input phone, 2: verify code, 3: reset password const code = ref(''); const isFocused = ref(false); const newPassword = ref(''); const confirmPassword = ref(''); const PASSWORD_REGEX = /^(?![0-9]+$)(?![a-zA-Z]+$)(?![^0-9a-zA-Z]+$).{8,32}$/ const phone = ref('') const step = ref(1) // 1: input phone, 2: verify code, 3: reset password const code = ref('') const isFocused = ref(false) const newPassword = ref('') const confirmPassword = ref('') const resetTicket = ref('') const loading = ref(false) interface ApiResponse<T> { code: number msg?: string message?: string data: T } const titleText = computed(() => { if (step.value === 1) return '忘记密码'; if (step.value === 2) return '已发送验证码'; if (step.value === 3) return '重设密码'; return ''; }); if (step.value === 1) return '忘记密码' if (step.value === 2) return '已发送验证码' if (step.value === 3) return '重设密码' return '' }) /** * 返回上一步或关闭页面,并同步清理当前步骤的临时状态。 */ const goBack = () => { if (step.value > 1) { step.value--; step.value-- if (step.value === 1) { code.value = ''; code.value = '' resetTicket.value = '' } else if (step.value === 2) { newPassword.value = ''; confirmPassword.value = ''; newPassword.value = '' confirmPassword.value = '' resetTicket.value = '' setTimeout(() => { isFocused.value = true; }, 100); isFocused.value = true }, 100) } } else { uni.navigateBack(); uni.navigateBack() } }; } const getCode = () => { if (!phone.value || phone.value.length !== 11) { /** * 发送忘记密码验证码,并进入验证码输入步骤。 */ const getCode = async () => { if (loading.value) { return } const normalizedPhone = normalizePhone() if (!normalizedPhone) { return } loading.value = true try { const response = await sendPasswordResetCode({ phone: normalizedPhone }) if (!isSuccessResponse(response)) { return } step.value = 2 code.value = '' resetTicket.value = '' setTimeout(() => { isFocused.value = true }, 100) uni.showToast({ title: '请输入正确的手机号', icon: 'none' }); return; title: '验证码已发送', icon: 'success' }) } finally { loading.value = false } // Simulate sending code step.value = 2; setTimeout(() => { isFocused.value = true; }, 100); }; } /** * 聚焦隐藏验证码输入框。 */ const focusCodeInput = () => { isFocused.value = true; }; isFocused.value = true } const verifyCode = () => { if (code.value.length !== 6) { uni.showToast({ title: '请输入6位验证码', icon: 'none' }); return; /** * 校验验证码并换取一次性重置凭证。 */ const verifyCode = async () => { if (loading.value) { return } // Proceed to reset password step.value = 3; }; const normalizedPhone = normalizePhone() if (!normalizedPhone) { return } const normalizedCode = normalizeCode() if (!normalizedCode) { return } loading.value = true try { const response = await verifyPasswordResetCode({ phone: normalizedPhone, code: normalizedCode }) if (!isSuccessResponse(response)) { return } resetTicket.value = response.data?.resetTicket || '' step.value = 3 } finally { loading.value = false } } const submitReset = () => { if (!newPassword.value || !confirmPassword.value) { uni.showToast({ title: '请填写完整密码', icon: 'none' }); return; /** * 提交新密码并完成忘记密码流程。 */ const submitReset = async () => { if (loading.value) { return } if (newPassword.value !== confirmPassword.value) { if (!resetTicket.value) { uni.showToast({ title: '重置凭证已失效,请重新验证', icon: 'none' }) step.value = 2 setTimeout(() => { isFocused.value = true }, 100) return } const normalizedPassword = normalizePassword(newPassword.value, '请输入新密码') if (!normalizedPassword) { return } const normalizedConfirmPassword = normalizePassword(confirmPassword.value, '请再次输入密码') if (!normalizedConfirmPassword) { return } if (normalizedPassword !== normalizedConfirmPassword) { uni.showToast({ title: '两次密码输入不一致', icon: 'none' }); return; }) return } // Password validation regex (at least 2 types of char/num/symbol, 8-32 length) const regex = /^(?![0-9]+$)(?![a-zA-Z]+$)(?![^0-9a-zA-Z]+$).{8,32}$/; if (!regex.test(newPassword.value)) { loading.value = true try { const response = await resetPassword({ resetTicket: resetTicket.value, password: normalizedPassword, confirmPassword: normalizedConfirmPassword }) if (!isSuccessResponse(response)) { return } uni.showToast({ title: '密码重置成功', icon: 'success' }) setTimeout(() => { uni.navigateBack() }, 1500) } finally { loading.value = false } } /** * 校验手机号输入是否合法。 */ const normalizePhone = () => { const normalizedPhone = (phone.value || '').trim() if (!/^1\d{10}$/.test(normalizedPhone)) { uni.showToast({ title: '请输入正确的手机号', icon: 'none' }) return '' } return normalizedPhone } /** * 校验验证码输入是否合法。 */ const normalizeCode = () => { const normalizedCode = (code.value || '').trim() if (!/^\d{6}$/.test(normalizedCode)) { uni.showToast({ title: '请输入6位验证码', icon: 'none' }) return '' } return normalizedCode } /** * 校验重置密码输入是否合法。 */ const normalizePassword = (value: string, emptyMessage: string) => { const normalizedPassword = (value || '').trim() if (!normalizedPassword) { uni.showToast({ title: emptyMessage, icon: 'none' }) return '' } if (!PASSWORD_REGEX.test(normalizedPassword)) { uni.showToast({ title: '密码格式不正确', icon: 'none' }); return; }) return '' } return normalizedPassword } uni.showToast({ title: '密码重置成功', icon: 'success' }); setTimeout(() => { uni.navigateBack(); }, 1500); }; /** * 判断接口响应是否为成功态。 */ const isSuccessResponse = (response: ApiResponse<unknown> | undefined) => { return !!response && (response.code === 0 || response.code === 200) } </script> <style lang="scss" scoped> @@ -286,4 +421,4 @@ 0%, 100% { opacity: 1; } 50% { opacity: 0; } } </style> </style> traditionalApp/pages/forgotPassword/service.js
New file @@ -0,0 +1,22 @@ import request from '@/utils/request.js' /** * 发送忘记密码验证码。 */ export const sendPasswordResetCode = (data) => { return request.post('/api/app/auth/password-reset/code', data) } /** * 校验忘记密码验证码并换取重置凭证。 */ export const verifyPasswordResetCode = (data) => { return request.post('/api/app/auth/password-reset/verify', data) } /** * 使用重置凭证提交新密码。 */ export const resetPassword = (data) => { return request.post('/api/app/auth/password-reset', data) } traditionalApp/pages/login/login.vue
@@ -58,7 +58,7 @@ <script setup lang="ts"> import { onLoad } from '@dcloudio/uni-app' import { onUnmounted, ref } from 'vue' import { sendSmsCode, smsLogin } from './service.js' import { passwordLogin, sendSmsCode, smsLogin } from './service.js' const loginType = ref<'code' | 'password'>('code') const phone = ref('') @@ -202,33 +202,24 @@ } /** * 执行登录动作,当前仅开放验证码登录。 * 执行登录动作,根据当前 tab 走验证码登录或密码登录。 */ const handleLogin = async () => { if (loading.value) { return } if (loginType.value === 'password') { uni.showToast({ title: '密码登录暂未开放', icon: 'none' }) return } const normalizedPhone = normalizePhone() if (!normalizedPhone) { return } const normalizedCode = normalizeCode() if (!normalizedCode) { return } loading.value = true try { const res = await smsLogin({ phone: normalizedPhone, code: normalizedCode }) const res = loginType.value === 'code' ? await loginWithCode(normalizedPhone) : await loginWithPassword(normalizedPhone) if (!res) { return } if (!isSuccessResponse(res)) { return } @@ -245,6 +236,34 @@ } finally { loading.value = false } } /** * 使用短信验证码完成登录。 */ const loginWithCode = (normalizedPhone: string) => { const normalizedCode = normalizeCode() if (!normalizedCode) { return null } return smsLogin({ phone: normalizedPhone, code: normalizedCode }) } /** * 使用手机号密码完成登录。 */ const loginWithPassword = (normalizedPhone: string) => { const normalizedPassword = normalizePassword() if (!normalizedPassword) { return null } return passwordLogin({ phone: normalizedPhone, password: normalizedPassword }) } /** @@ -296,6 +315,21 @@ } /** * 校验密码输入是否合法。 */ const normalizePassword = () => { const normalizedPassword = (credential.value || '').trim() if (!normalizedPassword) { uni.showToast({ title: '请输入密码', icon: 'none' }) return '' } return normalizedPassword } /** * 判断接口响应是否为成功态。 */ const isSuccessResponse = (response: ApiResponse<unknown> | undefined) => { traditionalApp/pages/login/service.js
@@ -22,6 +22,13 @@ } /** * 使用手机号密码完成 APP 登录。 */ export const passwordLogin = (data) => { return request.post('/api/app/auth/password-login', data) } /** * 确认上级用户邀请。 */ export const confirmSuperior = (params) => {