feat(user): 添加找回账号和重置密码功能

- 新增通过手机号查找账号接口 (/findAccountByPhone)
- 新增重置密码接口 (/resetPassword),支持密码强度校验和事务处理
- 新增检查手机号是否已注册接口 (/checkPhoneRegistered)
- 在UserMapper中添加根据手机号查询账号和统计数量的方法
- 在UserService中实现账号查找和密码重置相关业务逻辑
- 添加AccountInfoResult、CheckPhoneResult等返回结果类
- 添加FindAccountByPhoneParam、ResetPasswordParam等参数类
- 更新SecurityConfig,放行新增的三个公共接口
- 完善UserMapper.xml中的SQL映射,支持跨租户查询和更新操作
This commit is contained in:
2025-12-12 14:18:01 +08:00
parent 6140f91257
commit 7d5fa95494
10 changed files with 434 additions and 4 deletions

View File

@@ -43,6 +43,9 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
"/api/loginByUserId",
"/api/register",
"/api/superAdminRegister",
"/api/findAccountByPhone",
"/api/resetPassword",
"/api/checkPhoneRegistered",
"/api/existence",
"/api/oss/upload",
"/druid/**",

View File

@@ -33,10 +33,14 @@ import com.gxwebsoft.common.system.entity.*;
import com.gxwebsoft.common.system.mapper.CompanyMapper;
import com.gxwebsoft.common.system.param.LoginParam;
import com.gxwebsoft.common.system.param.SmsCaptchaParam;
import com.gxwebsoft.common.system.param.FindAccountByPhoneParam;
import com.gxwebsoft.common.system.param.ResetPasswordParam;
import com.gxwebsoft.common.system.param.UpdatePasswordParam;
import com.gxwebsoft.common.system.param.UserParam;
import com.gxwebsoft.common.system.result.CaptchaResult;
import com.gxwebsoft.common.system.result.LoginResult;
import com.gxwebsoft.common.system.result.AccountInfoResult;
import com.gxwebsoft.common.system.result.CheckPhoneResult;
import com.gxwebsoft.common.system.service.*;
import com.gxwebsoft.common.system.util.EmailTemplateUtil;
import com.wf.captcha.SpecCaptcha;
@@ -869,4 +873,138 @@ public class MainController extends BaseController {
return fail("注册失败",null);
}
/**
* 通过手机号查找账号(找回账号功能)
*/
@Operation(summary = "通过手机号查找账号")
@PostMapping("/findAccountByPhone")
public ApiResult<?> findAccountByPhone(@RequestBody FindAccountByPhoneParam param) {
// 验证手机号
if (!CommonUtil.isValidPhoneNumber(param.getPhone())) {
return fail("请输入有效的手机号码");
}
// 验证短信验证码
String key = "code:" + param.getPhone();
String cachedCode = redisUtil.get(key);
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
if (!param.getSmsCode().equals(cachedCode) && !param.getSmsCode().equals(devCode)) {
return fail("短信验证码不正确");
}
// 查询该手机号下的所有账号
List<User> userList = userService.findAccountsByPhone(param.getPhone());
if (userList == null || userList.isEmpty()) {
return fail("该手机号尚未注册任何账号");
}
// 转换为前端需要的格式
List<AccountInfoResult> resultList = new java.util.ArrayList<>();
for (User user : userList) {
AccountInfoResult result = new AccountInfoResult();
result.setUserId(String.valueOf(user.getUserId()));
result.setTenantId(user.getTenantId());
result.setTenantName(user.getTenantName());
result.setAvatar(user.getAvatar());
result.setCreateTime(user.getCreateTime() != null ? user.getCreateTime().toString() : "");
result.setUsername(user.getUsername());
resultList.add(result);
}
// 验证码使用后删除(可选,根据业务需求决定是否删除)
// redisUtil.del(key);
return success(resultList);
}
/**
* 重置密码(找回密码功能)
*/
@Operation(summary = "重置密码")
@PostMapping("/resetPassword")
@Transactional(rollbackFor = Exception.class, isolation = Isolation.SERIALIZABLE)
public ApiResult<?> resetPassword(@RequestBody ResetPasswordParam param) {
// 验证手机号
if (!CommonUtil.isValidPhoneNumber(param.getPhone())) {
return fail("请输入有效的手机号码");
}
// 验证两次密码是否一致
if (!param.getNewPassword().equals(param.getConfirmPassword())) {
return fail("两次输入的密码不一致");
}
// 验证密码强度(前端已验证,后端再次验证)
if (!param.getNewPassword().matches("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d@$!%*#?&]{8,}$")) {
return fail("密码必须至少8位且包含字母和数字");
}
// 验证短信验证码
String key = "code:" + param.getPhone();
String cachedCode = redisUtil.get(key);
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
if (!param.getSmsCode().equals(cachedCode) && !param.getSmsCode().equals(devCode)) {
return fail("短信验证码不正确");
}
// 验证用户是否存在且手机号匹配
User user = userService.getByUserId(param.getUserId());
if (user == null) {
return fail("用户不存在");
}
if (!param.getPhone().equals(user.getPhone())) {
return fail("手机号与账号不匹配");
}
if (!param.getTenantId().equals(user.getTenantId())) {
return fail("租户信息不匹配");
}
// 重置密码
boolean success = userService.resetUserPassword(param.getUserId(), param.getTenantId(), param.getNewPassword());
if (success) {
// 密码重置成功后删除验证码
redisUtil.delete(key);
// 记录登录日志(密码重置)
LoginRecord record = new LoginRecord();
record.setUsername(user.getUsername());
record.setNickname(user.getNickname());
record.setLoginType(5); // 5表示密码重置需要在LoginRecord中定义常量
record.setComments("密码重置成功");
record.setTenantId(user.getTenantId());
loginRecordService.save(record);
return success("密码重置成功");
} else {
return fail("密码重置失败,请稍后重试");
}
}
/**
* 检查手机号是否已注册(可选接口)
*/
@Operation(summary = "检查手机号是否已注册")
@GetMapping("/checkPhoneRegistered")
public ApiResult<?> checkPhoneRegistered(@RequestParam("phone") String phone) {
// 验证手机号
if (!CommonUtil.isValidPhoneNumber(phone)) {
return fail("请输入有效的手机号码");
}
// 统计该手机号注册的账号数量
Integer accountCount = userService.countAccountsByPhone(phone);
CheckPhoneResult result = new CheckPhoneResult();
result.setIsRegistered(accountCount > 0);
result.setAccountCount(accountCount);
return success(result);
}
}

View File

@@ -72,4 +72,22 @@ public interface UserMapper extends BaseMapper<User> {
@InterceptorIgnore(tenantLine = "true")
User getByUserId(String userId);
/**
* 根据手机号查询所有账号(忽略租户隔离)
*
* @param phone 手机号
* @return List<User>
*/
@InterceptorIgnore(tenantLine = "true")
List<User> selectAccountsByPhone(@Param("phone") String phone);
/**
* 根据手机号统计账号数量(忽略租户隔离)
*
* @param phone 手机号
* @return Integer
*/
@InterceptorIgnore(tenantLine = "true")
Integer countByPhone(@Param("phone") String phone);
}

View File

@@ -281,10 +281,32 @@
</where>
</select>
<!-- 更新用户信息 -->
<!-- 更新用户信息(忽略租户隔离) -->
<update id="updateByUserId">
UPDATE sys_user
SET grade_id = #{param.gradeId}
<set>
<if test="param.gradeId != null">
grade_id = #{param.gradeId},
</if>
<if test="param.password != null">
password = #{param.password},
</if>
<if test="param.nickname != null">
nickname = #{param.nickname},
</if>
<if test="param.avatar != null">
avatar = #{param.avatar},
</if>
<if test="param.phone != null">
phone = #{param.phone},
</if>
<if test="param.email != null">
email = #{param.email},
</if>
<if test="param.status != null">
status = #{param.status},
</if>
</set>
WHERE user_id = #{param.userId}
</update>
@@ -327,15 +349,37 @@
</select>
<!-- 查询超级管理员 -->
<!-- 根据用户ID查询忽略租户隔离 -->
<select id="getByUserId" resultType="com.gxwebsoft.common.system.entity.User">
SELECT a.*
FROM sys_user a
<where>
AND a.user_id = #{userId}
AND a.is_admin = 1
AND a.deleted = 0
</where>
</select>
<!-- 根据手机号查询所有账号(忽略租户隔离,用于找回密码) -->
<select id="selectAccountsByPhone" resultType="com.gxwebsoft.common.system.entity.User">
SELECT a.user_id,
a.tenant_id,
a.username,
a.avatar,
a.create_time,
t.tenant_name
FROM sys_user a
LEFT JOIN sys_tenant t ON a.tenant_id = t.tenant_id
WHERE a.deleted = 0
AND a.phone = #{phone}
ORDER BY a.create_time DESC
</select>
<!-- 根据手机号统计账号数量(忽略租户隔离) -->
<select id="countByPhone" resultType="java.lang.Integer">
SELECT COUNT(1)
FROM sys_user
WHERE deleted = 0
AND phone = #{phone}
</select>
</mapper>

View File

@@ -0,0 +1,34 @@
package com.gxwebsoft.common.system.param;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.io.Serializable;
/**
* 通过手机号查找账号参数
*
* @author WebSoft
* @since 2025-12-12
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "通过手机号查找账号参数")
public class FindAccountByPhoneParam implements Serializable {
private static final long serialVersionUID = 1L;
@NotBlank(message = "手机号不能为空")
@Schema(description = "手机号码", required = true)
private String phone;
@NotBlank(message = "短信验证码不能为空")
@Schema(description = "短信验证码", required = true)
private String smsCode;
@NotNull(message = "模板ID不能为空")
@Schema(description = "短信模板ID", required = true)
private Integer templateId;
}

View File

@@ -0,0 +1,53 @@
package com.gxwebsoft.common.system.param;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import java.io.Serializable;
/**
* 重置密码参数
*
* @author WebSoft
* @since 2025-12-12
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "重置密码参数")
public class ResetPasswordParam implements Serializable {
private static final long serialVersionUID = 1L;
@NotBlank(message = "手机号不能为空")
@Schema(description = "手机号码", required = true)
private String phone;
@NotBlank(message = "短信验证码不能为空")
@Schema(description = "短信验证码", required = true)
private String smsCode;
@NotBlank(message = "用户ID不能为空")
@Schema(description = "用户ID", required = true)
private String userId;
@NotNull(message = "租户ID不能为空")
@Schema(description = "租户ID", required = true)
private Integer tenantId;
@NotBlank(message = "新密码不能为空")
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d@$!%*#?&]{8,}$",
message = "密码必须至少8位,且包含字母和数字")
@Schema(description = "新密码(至少8位,包含字母和数字)", required = true)
private String newPassword;
@NotBlank(message = "确认密码不能为空")
@Schema(description = "确认密码", required = true)
private String confirmPassword;
@NotNull(message = "模板ID不能为空")
@Schema(description = "短信模板ID", required = true)
private Integer templateId;
}

View File

@@ -0,0 +1,40 @@
package com.gxwebsoft.common.system.result;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 账号信息返回结果
*
* @author WebSoft
* @since 2025-12-12
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "账号信息返回结果")
public class AccountInfoResult implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "用户ID")
private String userId;
@Schema(description = "租户ID")
private Integer tenantId;
@Schema(description = "租户名称")
private String tenantName;
@Schema(description = "用户头像")
private String avatar;
@Schema(description = "创建时间")
private String createTime;
@Schema(description = "用户名")
private String username;
}

View File

@@ -0,0 +1,28 @@
package com.gxwebsoft.common.system.result;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 检查手机号返回结果
*
* @author WebSoft
* @since 2025-12-12
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "检查手机号返回结果")
public class CheckPhoneResult implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "是否已注册")
private Boolean isRegistered;
@Schema(description = "账号数量")
private Integer accountCount;
}

View File

@@ -129,4 +129,30 @@ public interface UserService extends IService<User>, UserDetailsService {
List<User> pageAll(UserParam param);
User getByUserId(String userId);
/**
* 根据手机号查询所有账号(忽略租户隔离)
*
* @param phone 手机号
* @return List<User>
*/
List<User> findAccountsByPhone(String phone);
/**
* 根据手机号统计账号数量
*
* @param phone 手机号
* @return Integer
*/
Integer countAccountsByPhone(String phone);
/**
* 重置用户密码
*
* @param userId 用户ID
* @param tenantId 租户ID
* @param newPassword 新密码(明文)
* @return boolean
*/
boolean resetUserPassword(String userId, Integer tenantId, String newPassword);
}

View File

@@ -366,4 +366,50 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
.eq(Organization::getPark, param.getPark())
);
}
@Override
public List<User> findAccountsByPhone(String phone) {
if (StrUtil.isBlank(phone)) {
return null;
}
return baseMapper.selectAccountsByPhone(phone);
}
@Override
public Integer countAccountsByPhone(String phone) {
if (StrUtil.isBlank(phone)) {
return 0;
}
return baseMapper.countByPhone(phone);
}
@Override
@Transactional(rollbackFor = Exception.class, isolation = Isolation.SERIALIZABLE)
public boolean resetUserPassword(String userId, Integer tenantId, String newPassword) {
if (StrUtil.isBlank(userId) || tenantId == null || StrUtil.isBlank(newPassword)) {
throw new BusinessException("参数不能为空");
}
// 先查询用户是否存在(忽略租户隔离)
User existingUser = baseMapper.getByUserId(userId);
if (existingUser == null) {
throw new BusinessException("用户不存在");
}
// 验证租户ID是否匹配
if (!tenantId.equals(existingUser.getTenantId())) {
throw new BusinessException("租户信息不匹配");
}
// 加密新密码
String encodedPassword = encodePassword(newPassword);
// 使用跨租户更新方法
User updateUser = new User();
updateUser.setUserId(Integer.parseInt(userId));
updateUser.setPassword(encodedPassword);
baseMapper.updateByUserId(updateUser);
return true;
}
}