“mitao”
2026-04-07 42dd06b5f83968155817b942200676cf521bb7fe
feat:app帐号密码登录、忘记密码
7个文件已添加
8个文件已修改
990 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/controller/AppAuthController.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppPasswordLoginRequest.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppPasswordResetRequest.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/dto/AppPasswordResetVerifyRequest.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java 195 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthSessionService.java 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsSender.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/LoggingAppSmsSender.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/vo/AppPasswordResetVerifyResponse.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppAuthServiceTest.java 228 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/forgotPassword/forgotPassword.vue 275 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/forgotPassword/service.js 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/login/login.vue 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/login/service.js 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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) => {