From 42dd06b5f83968155817b942200676cf521bb7fe Mon Sep 17 00:00:00 2001
From: “mitao” <“2763622819@qq.com”>
Date: 星期二, 07 四月 2026 17:17:03 +0800
Subject: [PATCH] feat:app帐号密码登录、忘记密码

---
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java |  195 +++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 188 insertions(+), 7 deletions(-)

diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java
index 9b0420b..e3dccd0 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppAuthService.java
+++ b/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) {

--
Gitblit v1.7.1