chore: 项目环境升级为 JDK25,Spring 4.1,项目重构为多模块
This commit is contained in:
46
server/manuagent-admin/pom.xml
Normal file
46
server/manuagent-admin/pom.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>manuagent-parent</artifactId>
|
||||
<version>0.1.0</version>
|
||||
</parent>
|
||||
<artifactId>manuagent-admin</artifactId>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>tech.easyflow</groupId>
|
||||
<artifactId>manuagent-common</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mybatis-flex</groupId>
|
||||
<artifactId>mybatis-flex-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-crypto</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,112 @@
|
||||
package tech.easyflow.manuagent.admin.auth;
|
||||
|
||||
import com.mybatisflex.annotation.Id;
|
||||
import com.mybatisflex.annotation.KeyType;
|
||||
import com.mybatisflex.annotation.Table;
|
||||
import com.mybatisflex.annotation.Column;
|
||||
import com.mybatisflex.core.keygen.KeyGenerators;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
|
||||
|
||||
/**
|
||||
* 映射 {@code app.app_user} 表的管理员用户实体。
|
||||
*
|
||||
* <p>该实体只服务于数据库持久化,不直接作为 HTTP 接口的输入或输出。UUID 主键继续由应用层生成,
|
||||
* MyBatis-Flex 的 UUID 生成器只会在主键为空时补值,因此既能保留调用方已生成的 UUID,也能避免
|
||||
* 遗漏主键造成数据库约束错误。写入时使用 selective insert 保留已有 UUID,
|
||||
* 其余未赋值字段仍由数据库默认值负责填充。</p>
|
||||
*/
|
||||
@Table(value = "app_user", schema = "app")
|
||||
public class AppUserEntity {
|
||||
|
||||
/** 用户主键。 */
|
||||
@Id(keyType = KeyType.Generator, value = KeyGenerators.uuid)
|
||||
@Column(typeHandler = UuidTypeHandler.class)
|
||||
private UUID id;
|
||||
|
||||
/** 登录用户名。 */
|
||||
private String username;
|
||||
|
||||
/** Spring Security 使用的密码摘要。 */
|
||||
private String passwordHash;
|
||||
|
||||
/** 页面展示名称。 */
|
||||
private String displayName;
|
||||
|
||||
/** 账户是否允许登录。 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 最近一次登录时间。 */
|
||||
private OffsetDateTime lastLoginAt;
|
||||
|
||||
/** 创建时间。 */
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
/** 更新时间。 */
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(UUID id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
public void setPasswordHash(String passwordHash) {
|
||||
this.passwordHash = passwordHash;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public Boolean getEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public OffsetDateTime getLastLoginAt() {
|
||||
return lastLoginAt;
|
||||
}
|
||||
|
||||
public void setLastLoginAt(OffsetDateTime lastLoginAt) {
|
||||
this.lastLoginAt = lastLoginAt;
|
||||
}
|
||||
|
||||
public OffsetDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public OffsetDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package tech.easyflow.manuagent.admin.auth;
|
||||
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import tech.easyflow.manuagent.admin.auth.AppUserEntity;
|
||||
|
||||
/**
|
||||
* 提供 {@code app.app_user} 表的 MyBatis-Flex 基础数据访问能力。
|
||||
*
|
||||
* <p>用户表只有简单单表操作,因此直接使用 {@link BaseMapper} 和 Lambda QueryWrapper,
|
||||
* 不额外维护 Mapper XML。</p>
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Mapper
|
||||
public interface AppUserMapper extends BaseMapper<AppUserEntity> {
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package tech.easyflow.manuagent.admin.auth;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import java.util.UUID;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.admin.config.AdminProperties;
|
||||
import tech.easyflow.manuagent.admin.auth.AppUserEntity;
|
||||
import tech.easyflow.manuagent.admin.auth.AppUserMapper;
|
||||
|
||||
/**
|
||||
* 管理单管理员账户和当前用户标识。
|
||||
*/
|
||||
@Service
|
||||
public class UserService implements UserDetailsService {
|
||||
|
||||
private final AppUserMapper userMapper;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AdminProperties properties;
|
||||
|
||||
/**
|
||||
* 创建用户服务。
|
||||
*
|
||||
* @param userMapper 用户表 Mapper
|
||||
* @param passwordEncoder 密码编码器
|
||||
* @param properties 应用配置
|
||||
*/
|
||||
public UserService(AppUserMapper userMapper, PasswordEncoder passwordEncoder, AdminProperties properties) {
|
||||
this.userMapper = userMapper;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化本地管理员账户。
|
||||
*
|
||||
*/
|
||||
public void initializeAdministrator() {
|
||||
long count = userMapper.selectCountByQuery(QueryWrapper.create());
|
||||
if (count > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 首次启动时仍由应用层生成 UUID;selective insert 让 enabled 和时间字段沿用数据库默认值。
|
||||
AppUserEntity administrator = new AppUserEntity();
|
||||
administrator.setId(UUID.randomUUID());
|
||||
administrator.setUsername(properties.adminUsername());
|
||||
administrator.setPasswordHash(passwordEncoder.encode(properties.adminPassword()));
|
||||
administrator.setDisplayName("管理员");
|
||||
userMapper.insertSelective(administrator);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 Spring Security 用户。
|
||||
*
|
||||
* @param username 登录名
|
||||
* @return 用户详情
|
||||
* @throws UsernameNotFoundException 用户不存在或被禁用时抛出
|
||||
*/
|
||||
@Override
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 LambdaGetter 可变参数会产生安全的泛型数组警告。
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(
|
||||
AppUserEntity::getUsername,
|
||||
AppUserEntity::getPasswordHash,
|
||||
AppUserEntity::getEnabled)
|
||||
.where(AppUserEntity::getUsername).eq(username);
|
||||
AppUserEntity entity = userMapper.selectOneByQuery(query);
|
||||
if (entity == null) {
|
||||
throw new UsernameNotFoundException("账户不存在");
|
||||
}
|
||||
return User.withUsername(entity.getUsername())
|
||||
.password(entity.getPasswordHash())
|
||||
.roles("ADMIN")
|
||||
.disabled(!Boolean.TRUE.equals(entity.getEnabled()))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定登录名的用户 ID。
|
||||
*
|
||||
* @param username 登录名
|
||||
* @return 用户 UUID
|
||||
* @throws ApiException 用户不存在时抛出
|
||||
*/
|
||||
@SuppressWarnings("unchecked") // MyBatis-Flex 的 select(LambdaGetter<T>...) 使用泛型可变参数,调用本身类型安全。
|
||||
public UUID requireUserId(String username) {
|
||||
QueryWrapper query = QueryWrapper.create()
|
||||
.select(AppUserEntity::getId)
|
||||
.where(AppUserEntity::getUsername).eq(username);
|
||||
AppUserEntity entity = userMapper.selectOneByQuery(query);
|
||||
if (entity == null) {
|
||||
throw new ApiException(HttpStatus.UNAUTHORIZED, "USER_NOT_FOUND", "登录账户不存在");
|
||||
}
|
||||
return entity.getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package tech.easyflow.manuagent.admin.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** 首次启动时的管理员账号配置。 */
|
||||
@ConfigurationProperties(prefix = "app")
|
||||
public record AdminProperties(String adminUsername, String adminPassword) {
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package tech.easyflow.manuagent.admin.auth;
|
||||
|
||||
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.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.mybatisflex.core.FlexGlobalConfig;
|
||||
import com.mybatisflex.core.mybatis.FlexConfiguration;
|
||||
import javax.sql.DataSource;
|
||||
import java.util.UUID;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import tech.easyflow.manuagent.common.ApiException;
|
||||
import tech.easyflow.manuagent.admin.config.AdminProperties;
|
||||
import tech.easyflow.manuagent.admin.auth.AppUserEntity;
|
||||
import tech.easyflow.manuagent.admin.auth.AppUserMapper;
|
||||
import tech.easyflow.manuagent.common.typehandler.UuidTypeHandler;
|
||||
|
||||
/**
|
||||
* 验证 {@link UserService} 在迁移到 MyBatis-Flex 后保持原有管理员初始化与认证语义。
|
||||
*/
|
||||
class UserServiceTest {
|
||||
|
||||
private AppUserMapper mapper;
|
||||
private PasswordEncoder passwordEncoder;
|
||||
private AdminProperties properties;
|
||||
private UserService service;
|
||||
|
||||
/**
|
||||
* 为每个测试创建隔离的 Mapper 与安全组件替身。
|
||||
*/
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 生产环境由 Spring Boot 在 Mapper 使用前注册 TypeHandler;纯单测需显式建立同等的元数据环境。
|
||||
Environment environment = new Environment(
|
||||
"user-service-unit-test", new JdbcTransactionFactory(), mock(DataSource.class));
|
||||
FlexConfiguration configuration = new FlexConfiguration(environment);
|
||||
configuration.getTypeHandlerRegistry().register(UuidTypeHandler.class);
|
||||
FlexGlobalConfig globalConfig = new FlexGlobalConfig();
|
||||
globalConfig.setConfiguration(configuration);
|
||||
FlexGlobalConfig.setDefaultConfig(globalConfig);
|
||||
|
||||
mapper = mock(AppUserMapper.class);
|
||||
passwordEncoder = mock(PasswordEncoder.class);
|
||||
properties = mock(AdminProperties.class);
|
||||
service = new UserService(mapper, passwordEncoder, properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次启动时应创建一个启用的管理员,并保存编码后的密码。
|
||||
*/
|
||||
@Test
|
||||
void shouldCreateInitialAdministratorWhenUserTableIsEmpty() {
|
||||
when(mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(0L);
|
||||
when(properties.adminUsername()).thenReturn("admin");
|
||||
when(properties.adminPassword()).thenReturn("plain-password");
|
||||
when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password");
|
||||
|
||||
service.initializeAdministrator();
|
||||
|
||||
ArgumentCaptor<AppUserEntity> captor = ArgumentCaptor.forClass(AppUserEntity.class);
|
||||
verify(mapper).insertSelective(captor.capture());
|
||||
AppUserEntity created = captor.getValue();
|
||||
assertThat(created.getId()).isNotNull();
|
||||
assertThat(created.getUsername()).isEqualTo("admin");
|
||||
assertThat(created.getPasswordHash()).isEqualTo("encoded-password");
|
||||
assertThat(created.getDisplayName()).isEqualTo("管理员");
|
||||
}
|
||||
|
||||
/**
|
||||
* 已存在用户时不得重复创建默认管理员。
|
||||
*/
|
||||
@Test
|
||||
void shouldNotCreateAdministratorWhenAnyUserExists() {
|
||||
when(mapper.selectCountByQuery(any(QueryWrapper.class))).thenReturn(1L);
|
||||
|
||||
service.initializeAdministrator();
|
||||
|
||||
verify(mapper, never()).insertSelective(any(AppUserEntity.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户查询结果应转换为 Spring Security 用户详情,并保留禁用状态。
|
||||
*/
|
||||
@Test
|
||||
void shouldLoadSecurityUserAndResolveUserId() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUserEntity entity = new AppUserEntity();
|
||||
entity.setId(userId);
|
||||
entity.setUsername("admin");
|
||||
entity.setPasswordHash("encoded-password");
|
||||
entity.setEnabled(false);
|
||||
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(entity);
|
||||
|
||||
var details = service.loadUserByUsername("admin");
|
||||
|
||||
assertThat(details.getUsername()).isEqualTo("admin");
|
||||
assertThat(details.getPassword()).isEqualTo("encoded-password");
|
||||
assertThat(details.isEnabled()).isFalse();
|
||||
assertThat(service.requireUserId("admin")).isEqualTo(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录认证查询应保持迁移前 SQL 的三个必要字段,避免读取展示名和审计时间。
|
||||
*/
|
||||
@Test
|
||||
void shouldSelectOnlyAuthenticationColumnsWhenLoadingUser() {
|
||||
AppUserEntity entity = new AppUserEntity();
|
||||
entity.setUsername("admin");
|
||||
entity.setPasswordHash("encoded-password");
|
||||
entity.setEnabled(true);
|
||||
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(entity);
|
||||
|
||||
service.loadUserByUsername("admin");
|
||||
|
||||
ArgumentCaptor<QueryWrapper> queryCaptor = ArgumentCaptor.forClass(QueryWrapper.class);
|
||||
verify(mapper).selectOneByQuery(queryCaptor.capture());
|
||||
String sql = queryCaptor.getValue().toSQL().toLowerCase(java.util.Locale.ROOT);
|
||||
assertThat(sql)
|
||||
.contains("username", "password_hash", "enabled")
|
||||
.doesNotContain("display_name", "last_login_at", "created_at", "updated_at");
|
||||
}
|
||||
|
||||
/**
|
||||
* 不存在的登录名应分别维持认证层和接口层原有的异常类型。
|
||||
*/
|
||||
@Test
|
||||
void shouldRejectMissingUser() {
|
||||
when(mapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.loadUserByUsername("missing"))
|
||||
.isInstanceOf(UsernameNotFoundException.class);
|
||||
assertThatThrownBy(() -> service.requireUserId("missing"))
|
||||
.isInstanceOf(ApiException.class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user