chore: 项目环境升级为 JDK25,Spring 4.1,项目重构为多模块

This commit is contained in:
2026-09-08 15:02:18 +08:00
parent d392436620
commit 81a0d81f11
168 changed files with 2785 additions and 1375 deletions

View 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>

View File

@@ -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;
}
}

View File

@@ -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> {
}

View File

@@ -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;
}
// 首次启动时仍由应用层生成 UUIDselective 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();
}
}

View File

@@ -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) {
}

View File

@@ -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);
}
}