fix(app): 修复 AppController 安全与查询问题

- 加强异常处理、类型安全和图片上传校验
- 优化设备相关查询,避免重复访问数据源
- 补充并记录并发测试与审查修复实施计划
This commit is contained in:
yuhaiming
2026-07-20 15:45:13 +08:00
parent b6c1edaeba
commit 215d6ba167
20 changed files with 664 additions and 91 deletions

View File

@@ -100,6 +100,12 @@
<artifactId>water-common-sse</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -232,4 +232,14 @@ public interface ISysUserService {
int updateAppUser(SysUserBo user);
int updateUserPas(SysUserBo userBo);
/**
* 通过账号标识和验证码重置密码。
*
* @param username 手机号、邮箱或账号名
* @param code 验证码
* @param password 新密码明文
* @return 是否重置成功
*/
boolean resetPasswordByVerificationCode(String username, String code, String password);
}

View File

@@ -16,16 +16,17 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.CacheNames;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.domain.dto.UserDTO;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.service.UserService;
import org.dromara.common.core.utils.*;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.mybatis.helper.DataPermissionHelper;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.SysUser;
@@ -414,6 +415,54 @@ public class SysUserServiceImpl implements ISysUserService, UserService {
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean resetPasswordByVerificationCode(String username, String code, String password) {
if (StringUtils.isBlank(username)) {
throw new ServiceException("账号不能为空");
}
if (StringUtils.isBlank(code)) {
throw new ServiceException("验证码不能为空");
}
if (StringUtils.isBlank(password)) {
throw new ServiceException("新密码不能为空");
}
LambdaQueryWrapper<SysUser> query = Wrappers.lambdaQuery();
query.eq(SysUser::getDelFlag, SystemConstants.NORMAL);
if (Validator.isEmail(username)) {
query.eq(SysUser::getEmail, username);
} else if (Validator.isMobile(username)) {
query.eq(SysUser::getPhonenumber, username);
} else {
query.eq(SysUser::getUserName, username);
}
SysUser user = baseMapper.selectOne(query);
if (user == null) {
throw new UserException("账号未注册");
}
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
String cachedCode = RedisUtils.getCacheObject(cacheKey);
if (StringUtils.isBlank(cachedCode)) {
throw new CaptchaExpireException();
}
if (!StringUtils.equals(cachedCode, code)) {
throw new UserException("验证码无效");
}
SysUser update = new SysUser();
update.setUserId(user.getUserId());
update.setPassword(BCrypt.hashpw(password));
int updatedRows = DataPermissionHelper.ignore(() -> baseMapper.updateById(update));
if (updatedRows < 1) {
throw new ServiceException("忘记密码修改失败");
}
RedisUtils.deleteObject(cacheKey);
return true;
}
/**
* 校验短信验证码
*/

View File

@@ -0,0 +1,205 @@
package org.dromara.system.service.impl;
import cn.hutool.crypto.digest.BCrypt;
import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.core.plugins.InterceptorIgnoreHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.system.domain.SysUser;
import org.dromara.system.mapper.*;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.redisson.api.RedissonClient;
import org.springframework.context.support.GenericApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class SysUserServiceImplPasswordRecoveryTest {
private static GenericApplicationContext applicationContext;
@Mock private SysUserMapper userMapper;
@Mock private SysDeptMapper deptMapper;
@Mock private SysRoleMapper roleMapper;
@Mock private SysPostMapper postMapper;
@Mock private SysUserRoleMapper userRoleMapper;
@Mock private SysUserPostMapper userPostMapper;
@Captor private ArgumentCaptor<LambdaQueryWrapper<SysUser>> queryCaptor;
@Captor private ArgumentCaptor<SysUser> userCaptor;
@BeforeAll
static void initializeInfrastructure() {
if (TableInfoHelper.getTableInfo(SysUser.class) == null) {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), ""), SysUser.class);
}
applicationContext = new GenericApplicationContext();
applicationContext.registerBean(RedissonClient.class, () -> mock(RedissonClient.class));
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
}
@AfterAll
static void closeInfrastructure() {
applicationContext.close();
}
@ParameterizedTest
@CsvSource({
"alice@example.com,email",
"13305376054,phonenumber",
"alice,userName"
})
void resetPasswordByVerificationCode_supportsAllAccountIdentifiers(
String username, String expectedColumn) {
SysUserServiceImpl service = newService();
SysUser user = user(10L, username);
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
when(userMapper.selectOne(any())).thenReturn(user);
when(userMapper.updateById(any(SysUser.class))).thenReturn(1);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn("123456");
redis.when(() -> RedisUtils.deleteObject(cacheKey)).thenReturn(true);
boolean result = service.resetPasswordByVerificationCode(username, "123456", "newPassword");
assertThat(result).isTrue();
verify(userMapper).selectOne(queryCaptor.capture());
assertThat(queryCaptor.getValue().getSqlSegment()).contains(expectedColumn);
assertThat(queryCaptor.getValue().getParamNameValuePairs()).containsValue(username);
verify(userMapper).updateById(userCaptor.capture());
assertThat(userCaptor.getValue().getUserId()).isEqualTo(10L);
assertThat(BCrypt.checkpw("newPassword", userCaptor.getValue().getPassword())).isTrue();
redis.verify(() -> RedisUtils.deleteObject(cacheKey));
}
}
@Test
void resetPasswordByVerificationCode_rejectsWrongCodeWithoutUpdatingOrDeleting() {
SysUserServiceImpl service = newService();
String username = "13305376054";
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
when(userMapper.selectOne(any())).thenReturn(user(10L, username));
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn("123456");
assertThatThrownBy(() ->
service.resetPasswordByVerificationCode(username, "000000", "newPassword"))
.isInstanceOf(UserException.class);
verify(userMapper, never()).updateById(any(SysUser.class));
redis.verify(() -> RedisUtils.deleteObject(cacheKey), never());
}
}
@Test
void resetPasswordByVerificationCode_rejectsExpiredCodeWithoutUpdating() {
SysUserServiceImpl service = newService();
String username = "alice@example.com";
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
when(userMapper.selectOne(any())).thenReturn(user(10L, username));
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn(null);
assertThatThrownBy(() ->
service.resetPasswordByVerificationCode(username, "123456", "newPassword"))
.isInstanceOf(CaptchaExpireException.class);
verify(userMapper, never()).updateById(any(SysUser.class));
redis.verify(() -> RedisUtils.deleteObject(cacheKey), never());
}
}
@Test
void resetPasswordByVerificationCode_keepsCodeWhenDatabaseUpdateFails() {
SysUserServiceImpl service = newService();
String username = "alice";
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
when(userMapper.selectOne(any())).thenReturn(user(10L, username));
when(userMapper.updateById(any(SysUser.class))).thenReturn(0);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn("123456");
assertThatThrownBy(() ->
service.resetPasswordByVerificationCode(username, "123456", "newPassword"))
.isInstanceOf(ServiceException.class);
redis.verify(() -> RedisUtils.deleteObject(cacheKey), never());
}
}
@Test
void resetPasswordByVerificationCode_ignoresLoginBasedDataPermissionDuringUpdate() {
SysUserServiceImpl service = newService();
String username = "alice";
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
when(userMapper.selectOne(any())).thenReturn(user(10L, username));
when(userMapper.updateById(any(SysUser.class))).thenAnswer(invocation -> {
assertThat(InterceptorIgnoreHelper.willIgnoreDataPermission("SysUserMapper.updateById"))
.isTrue();
return 1;
});
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn("123456");
redis.when(() -> RedisUtils.deleteObject(cacheKey)).thenReturn(true);
assertThat(service.resetPasswordByVerificationCode(
username, "123456", "newPassword")).isTrue();
}
}
@Test
void resetPasswordByVerificationCode_rejectsUnknownAccountBeforeReadingCode() {
SysUserServiceImpl service = newService();
when(userMapper.selectOne(any())).thenReturn(null);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
assertThatThrownBy(() ->
service.resetPasswordByVerificationCode("missing", "123456", "newPassword"))
.isInstanceOf(UserException.class);
redis.verifyNoInteractions();
verify(userMapper, never()).updateById(any(SysUser.class));
}
}
private SysUserServiceImpl newService() {
return new SysUserServiceImpl(
userMapper, deptMapper, roleMapper, postMapper, userRoleMapper, userPostMapper);
}
private SysUser user(Long userId, String username) {
SysUser user = new SysUser();
user.setUserId(userId);
user.setUserName(username);
user.setEmail(username);
user.setPhonenumber(username);
return user;
}
}