diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml new file mode 100644 index 0000000..bc0f32b --- /dev/null +++ b/.github/workflows/ci-test.yml @@ -0,0 +1,36 @@ +name: CI Test + +on: + push: + branches: + - '**' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache Maven packages + uses: actions/cache@v3 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + + - name: Install dependencies + run: | + mvn install -DskipTests -s .mvn/settings.xml + + - name: Run unit tests + run: | + cd structure-infra-sample + mvn test -s ../.mvn/settings.xml \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e65c59e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: Release to Maven Central + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g., 1.0.1)' + required: true + type: string + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + server-id: oss + server-username: MAVEN_USERNAME + server-password: MAVEN_PASSWORD + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: GPG_PASSPHRASE + + - name: Cache Maven packages + uses: actions/cache@v3 + with: + path: ~/.m2 + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-m2 + + - name: Set release version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "RELEASE_VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV + else + echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + fi + + - name: Deploy to Maven Central + run: | + mvn clean deploy -P release,oss -Dmaven.test.skip=true -Drevision=$RELEASE_VERSION -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true -s .mvn/settings.xml + env: + OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f133ab1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ +*.tgz + +**/.DS_Store \ No newline at end of file diff --git a/.mvn/settings.xml b/.mvn/settings.xml new file mode 100644 index 0000000..5366497 --- /dev/null +++ b/.mvn/settings.xml @@ -0,0 +1,31 @@ + + + + + + oss + ${OSSRH_USERNAME} + ${OSSRH_PASSWORD} + + + gpg.passphrase + ${GPG_PASSPHRASE} + + + + + + oss + + false + + + gpg + ${GPG_PASSPHRASE} + + + + + \ No newline at end of file diff --git a/README.md b/README.md index ec8bdb0..2a67648 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,526 @@ # structure-pro-infra -基础设施 + +基于 DDD(领域驱动设计)理念的基础设施抽象层,提供统一的仓储接口和多种持久化技术的适配实现。 + +## 项目简介 + +该项目实现了一个基于 **Facade + Delegate** 模式的仓储抽象层,作为领域层与持久化层之间的防腐层(ACL),核心目标是: + +- **解耦领域模型与持久化技术**:领域层只依赖统一的仓储接口,不关心底层使用哪种数据库 +- **支持多持久化技术**:通过委托模式自动适配 MyBatis Plus、JPA、MongoDB、Elasticsearch 等 +- **自动配置**:基于 Spring Boot AutoConfiguration 实现开箱即用 +- **Entity-PO 自动转换**:RepositoryFacade 自动完成领域实体与持久化对象的转换 +- **CQRS 读写分离**:支持一个仓储配置多个代理,写操作走基础代理,读操作走读代理 +- **低代码仓储**:无需定义实体类,通过资源名称和 Map 动态操作数据,支持运行时动态注册 + +## 模块结构 + +``` +structure-pro-infra/ +├── structure-infra-starter/ # 核心模块 +│ ├── annotations/ # 注解定义 +│ │ ├── Repository.java # @Repository 注解 +│ │ └── DelegateFor.java # @DelegateFor 注解 +│ ├── configuration/ # 自动配置 +│ ├── repository/ # 仓储核心接口 +│ │ ├── RepositoryFacade.java # 仓储门面(对外) +│ │ ├── RepositoryDelegate.java # 仓储委托(对内) +│ │ ├── RepositoryDelegateFactory.java # 委托工厂接口 +│ │ ├── RepositoryType.java # 仓储类型枚举 +│ │ ├── DelegateType.java # 委托类型枚举(BASE/READ) +│ │ └── InMemoryRepositoryDelegate.java # 内存实现(开发/测试用) +│ ├── lowcode/ # 低代码仓储 +│ │ ├── repository/ # 低代码仓储接口 +│ │ │ ├── LowCodeRepository.java # 低代码统一仓储接口(用户侧) +│ │ │ ├── LowCodeStorage.java # 低代码存储接口(引擎侧) +│ │ │ └── LowCodeRepoFactory.java # 低代码仓储工厂接口 +│ │ ├── router/ # 路由引擎 +│ │ │ └── LowCodeRepositoryRouter.java # 低代码仓储路由器 +│ │ ├── model/ # 模型定义 +│ │ │ ├── ResourceSchema.java # 资源 schema +│ │ │ ├── FieldSchema.java # 字段 schema +│ │ │ ├── StorageType.java # 存储类型枚举 +│ │ │ ├── FieldType.java # 字段类型枚举 +│ │ │ ├── AutoFillType.java # 自动填充类型枚举 +│ │ │ └── RepositoryConfig.java # 仓储配置 +│ │ └── registry/ # 注册与构建 +│ │ └── ResourceSchemaBuilder.java # 资源 schema 构建器 +│ └── event/ # 事件管理 +├── structure-infra-mybatis-plus-starter/ # MyBatis Plus 适配(含低代码实现) +├── structure-infra-jpa-starter/ # JPA 适配 +├── structure-infra-mongodb-starter/ # MongoDB 适配(含低代码实现) +├── structure-infra-elasticsearch-starter/ # Elasticsearch 适配(含低代码实现) +└── structure-infra-sample/ # 示例模块 + ├── structure-infra-sample-core/ # 共享核心(Entity、PO、Repository接口) + ├── structure-infra-sample-mybatis/ # MyBatis Plus 示例(含低代码测试) + ├── structure-infra-sample-jpa/ # JPA 示例 + ├── structure-infra-sample-mongodb/ # MongoDB 示例(含低代码测试) + ├── structure-infra-sample-elasticsearch/ # Elasticsearch 示例(含低代码测试) + └── structure-infra-sample-cqrs/ # CQRS 读写分离示例 +``` + +## 核心概念 + +### RepositoryFacade + +仓储门面,是领域层直接调用的接口,负责: + +- 定义统一的 CRUD 操作契约 +- 自动完成 Entity(领域实体)与 PO(持久化对象)的转换 +- 内部持有 RepositoryDelegate 进行实际的持久化操作 +- 支持 CQRS 模式:持有 baseDelegate(写)和 readDelegate(读)两个代理 + +### RepositoryDelegate + +仓储委托,是持久化层的实现接口,负责: + +- 直接操作 PO(持久化对象) +- 与具体的持久化技术交互(MyBatis Plus、JPA、MongoDB 等) +- 不同持久化技术提供各自的实现 + +### RepositoryDelegateFactory + +委托工厂,用于自动创建 RepositoryDelegate 实例: + +- 各个持久化技术的 starter 模块实现此接口 +- 当找不到用户自定义的 delegate 时,通过工厂自动创建 + +### RepositoryType + +仓储类型枚举,支持的类型: + +| 类型 | 说明 | +|-----|------| +| `MYBATIS` | MyBatis | +| `MYBATIS_PLUS` | MyBatis Plus | +| `JPA` | Spring Data JPA | +| `JDBC` | JDBC | +| `NOSQL` | 通用 NoSQL | +| `REDIS` | Redis | +| `MONGODB` | MongoDB | +| `ELASTICSEARCH` | Elasticsearch | +| `AUTO` | 自动检测 | + +### DelegateType + +委托类型枚举,用于区分读写代理: + +| 类型 | 说明 | +|-----|------| +| `BASE` | 基础代理,承担写操作和默认读操作 | +| `READ` | 读代理,专门承担读操作(CQRS 模式下使用) | + +### 低代码仓储 + +低代码仓储是一套无需定义实体类和 PO 类的动态数据访问方案,通过资源名称和 `Map` 来操作数据。 + +**核心特点**: +- **零实体类**:无需定义 Java 实体类,通过 DSL/配置动态定义资源结构 +- **动态注册**:支持运行时动态注册新资源,无需重启应用 +- **多存储引擎**:同一套 API 支持 MySQL、MongoDB、Elasticsearch 等多种存储 +- **自动建表**:资源注册时自动创建表/集合/索引 +- **自动填充**:支持创建时间、更新时间等字段自动填充 +- **统一路由**:通过 `LowCodeRepositoryRouter` 统一路由到对应存储引擎 + +**核心组件**: + +| 组件 | 说明 | +|------|------| +| `LowCodeRepository` | 用户侧统一接口,方法名与 `ICrudRepository` 一致 | +| `LowCodeStorage` | 存储引擎侧接口,各存储引擎实现此接口 | +| `LowCodeRepoFactory` | 仓储工厂,创建具体的 `LowCodeStorage` 实例 | +| `LowCodeRepositoryRouter` | 路由引擎,根据资源名路由到对应存储 | +| `ResourceSchema` | 资源 schema 定义,描述资源的字段、索引等 | +| `FieldSchema` | 字段 schema 定义,描述单个字段的属性 | + +**支持的存储类型**: + +| 类型 | 实现模块 | 说明 | +|------|---------|------| +| `MYSQL` | structure-infra-mybatis-plus-starter | 基于 MyBatis Plus 实现 | +| `MONGODB` | structure-infra-mongodb-starter | 基于 MongoTemplate + Document 实现 | +| `ELASTICSEARCH` | structure-infra-elasticsearch-starter | 基于 ElasticsearchOperations + Map 实现 | +| `REDIS` | - | 规划中 | +| `IN_MEMORY` | - | 规划中(测试用) | + +## 快速开始 + +### 示例模块 + +项目提供了完整的示例模块,包含 REST API 接口,可用于快速测试和学习: + +**MongoDB 示例**(端口 8081): +```bash +mvn spring-boot:run -pl structure-infra-sample/structure-infra-sample-mongodb +``` + +**Elasticsearch 示例**(端口 8082): +```bash +mvn spring-boot:run -pl structure-infra-sample/structure-infra-sample-elasticsearch +``` + +**REST API 接口**(两个示例模块接口一致): + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/users` | 创建用户 | +| GET | `/api/users/{id}` | 根据ID查询 | +| GET | `/api/users/list` | 查询全部用户列表 | +| GET | `/api/users/page?page=1&size=10` | 分页查询 | +| PUT | `/api/users/{id}` | 更新用户 | +| DELETE | `/api/users/{id}` | 删除用户 | +| POST | `/api/users/batch` | 批量创建 | +| GET | `/api/users/count` | 查询总数 | + +详细示例模块说明请参考 [SAMPLE_MODULES.md](./SAMPLE_MODULES.md)。 + +### 1. 添加依赖 + +根据需要选择对应的 starter: + +```xml + + + cn.structured + structure-infra-starter + 1.0.0-SNAPSHOT + + + + + cn.structured + structure-infra-mybatis-plus-starter + 1.0.0-SNAPSHOT + + + + + cn.structured + structure-infra-jpa-starter + 1.0.0-SNAPSHOT + + + + + cn.structured + structure-infra-mongodb-starter + 1.0.0-SNAPSHOT + + + + + cn.structured + structure-infra-elasticsearch-starter + 1.0.0-SNAPSHOT + +``` + +### 2. 定义领域实体和持久化对象 + +```java +// 领域实体 +public class User { + private Long id; + private String name; + private String email; + // getters and setters +} + +// 持久化对象(PO) +public class UserPO { + private Long id; + private String name; + private String email; + // getters and setters +} +``` + +### 3. 创建 RepositoryFacade + +```java +@Repository(entity = User.class, po = UserPO.class, type = RepositoryType.MYBATIS_PLUS) +public class UserRepository extends RepositoryFacade> { + // 可添加自定义方法 +} +``` + +### 4. 使用仓储 + +```java +@Service +public class UserService { + + @Autowired + private UserRepository userRepository; + + public User saveUser(User user) { + return userRepository.save(user); + } + + public User getUserById(Long id) { + return userRepository.findById(id); + } + + public List findUsers(User condition) { + return userRepository.queryList(condition); + } + + public ResPage findUsersPage(ReqPage reqPage) { + return userRepository.queryPage(reqPage); + } +} +``` + +### 5. CQRS 读写分离模式(可选) + +当需要读写分离时,可以为同一个仓储配置两个代理。**必须同时满足 `cqrs=true` 和 `readDelegateClass` 指定才会启用读代理**。 + +**1) 启用 CQRS** + +```java +@Repository( + entity = User.class, + po = UserPO.class, + type = RepositoryType.MYBATIS_PLUS, + cqrs = true, // 启用 CQRS + readDelegateClass = UserReadDelegate.class // 指定读代理类 +) +public class UserRepository extends RepositoryFacade> { +} +``` + +**2) 定义写代理(BASE)** + +```java +@DelegateFor( + name = "userRepository", + po = UserPO.class, + type = RepositoryType.MYBATIS_PLUS, + delegateType = DelegateType.BASE // 写代理 +) +public class UserWriteDelegate extends MybatisPlusRepositoryDelegate { +} +``` + +**3) 定义读代理(READ)** + +```java +@DelegateFor( + name = "userRepository", + po = UserPO.class, + type = RepositoryType.ELASTICSEARCH, + delegateType = DelegateType.READ // 读代理 +) +public class UserReadDelegate extends ElasticsearchRepositoryDelegate { +} +``` + +**读操作回退机制**: +- 写操作(save、removeById、saveBatch、removeBatchByIds)始终走 BASE 代理(MyBatis Plus) +- 读操作(findById、queryList、queryPage、count、exists 等)优先走 READ 代理(Elasticsearch) +- 如果 READ 代理执行失败(抛出异常),自动回退到 BASE 代理执行 +- BASE 代理是最后的兜底,确保读操作始终可用 + +### 6. 低代码仓储使用 + +低代码仓储无需定义实体类,通过资源名称和 Map 操作数据。 + +**1) 定义资源 Schema** + +```java +ResourceSchema schema = ResourceSchema.builder() + .resourceName("article") + .tableName("t_lowcode_article") + .build(); + +schema.addField(FieldSchema.builder() + .name("id") + .type(FieldType.LONG) + .primaryKey(true) + .build()); + +schema.addField(FieldSchema.builder() + .name("title") + .type(FieldType.STRING) + .length(200) + .nullable(false) + .build()); + +schema.addField(FieldSchema.builder() + .name("author") + .type(FieldType.STRING) + .index(true) + .build()); + +schema.addField(FieldSchema.builder() + .name("created_at") + .type(FieldType.DATETIME) + .autoFill(AutoFillType.CREATE) + .build()); +``` + +**2) 注册资源并使用** + +```java +@Autowired +private LowCodeRepositoryRouter lowCodeRepositoryRouter; + +// 注册资源(通常在启动时或配置中完成) +RepositoryConfig config = new RepositoryConfig(); +config.setType(StorageType.MONGODB); +lowCodeRepositoryRouter.registerResource("article", schema, config); + +// 保存数据 +Map article = new HashMap<>(); +article.put("title", "Hello World"); +article.put("author", "zhangsan"); +Map saved = lowCodeRepositoryRouter.save("article", article); + +// 查询数据 +Map found = lowCodeRepositoryRouter.findById("article", 1L); + +// 条件查询 +Map params = new HashMap<>(); +params.put("author", "zhangsan"); +List> list = lowCodeRepositoryRouter.queryList("article", params); + +// 分页查询 +ReqPage reqPage = new ReqPage(); +reqPage.setPage(1); +reqPage.setSize(10); +ResPage> page = lowCodeRepositoryRouter.queryPage("article", reqPage); +``` + +**3) 切换存储引擎** + +只需修改 `RepositoryConfig` 的 `type` 即可切换存储引擎,业务代码无需修改: + +```java +// 使用 MySQL +config.setType(StorageType.MYSQL); + +// 使用 MongoDB +config.setType(StorageType.MONGODB); + +// 使用 Elasticsearch +config.setType(StorageType.ELASTICSEARCH); +``` + +## 注解说明 + +### @Repository + +标注在 `RepositoryFacade` 子类上,配置仓储属性: + +| 属性 | 类型 | 默认值 | 说明 | +|-----|------|-------|------| +| `value` | String | "" | 仓储名称 | +| `type` | RepositoryType | AUTO | 仓储类型 | +| `entity` | Class | Object.class | 领域实体类 | +| `po` | Class | Object.class | 持久化对象类 | +| `id` | Class | Long.class | 主键类型 | +| `description` | String | "" | 仓储描述 | +| `cache` | boolean | false | 是否启用缓存 | +| `cacheTime` | long | 60 | 缓存时间 | +| `cacheTimeUnit` | TimeUnit | SECONDS | 缓存时间单位 | +| `cqrs` | boolean | false | 是否启用 CQRS 读写分离 | + +### @DelegateFor + +标注在自定义 `RepositoryDelegate` 实现类上,用于注册委托: + +| 属性 | 类型 | 默认值 | 说明 | +|-----|------|-------|------| +| `name` | String | "" | 仓储名称(对应 RepositoryFacade 的 Bean 名称) | +| `type` | RepositoryType | AUTO | 存储类型 | +| `po` | Class | Object.class | 持久化对象类型 | +| `description` | String | "" | 描述 | +| `priority` | int | 0 | 优先级(数字越大优先级越高) | +| `delegateType` | DelegateType | BASE | 委托类型(BASE=写/默认,READ=读) | + +## 配置项 + +```yaml +structure: + infra: + default-event-channel: SPRING_EVENT # 默认事件通道:DEFAULT, SPRING_EVENT, MESSAGE_EVENT + cqrs: false # 是否开启 CQRS + cache-time: 60 # 默认缓存时间 + cache-time-unit: SECONDS # 默认缓存时间单位 +``` + +## 扩展指南 + +### 自定义 RepositoryDelegate + +当默认实现无法满足需求时,可以自定义委托实现: + +```java +@DelegateFor(name = "userRepository", po = UserPO.class, type = RepositoryType.MYBATIS_PLUS) +public class UserMybatisPlusDelegate extends MybatisPlusRepositoryDelegate { + + // 自定义查询方法 + public List findByEmailLike(String emailPattern) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.like("email", emailPattern); + return baseMapper.selectList(wrapper); + } +} +``` + +### 新增持久化技术支持 + +1. 创建 `RepositoryDelegate` 实现类 +2. 创建 `RepositoryDelegateFactory` 实现类 +3. 创建 `XXXDelegateBeanPostProcessor` 用于注入依赖 +4. 创建 `XXXAutoConfiguration` 自动配置类 +5. 在 `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` 中注册配置类 + +## 委托匹配策略 + +`RepositoryBeanPostProcessor` 在应用启动时自动匹配 delegate 到 facade。 + +### BASE 代理匹配优先级 + +1. 匹配 delegate 类型 + 名称 + 仓储类型 +2. 匹配 delegate 类型 + 仓储类型 +3. 匹配 delegate 类型 + 名称 +4. 匹配 delegate 类型 +5. 匹配名称 +6. 匹配 PO 类型 +7. 自动创建(通过 RepositoryDelegateFactory) +8. 使用默认的 InMemoryRepositoryDelegate + +### READ 代理匹配优先级(CQRS 启用时) + +与 BASE 代理匹配策略相同,但只匹配 `delegateType = READ` 的委托。如果未找到 READ 代理,读操作会回退到使用 BASE 代理。 + +## 事件管理 + +项目提供事件发布能力: + +```java +public interface Event { + String getEventId(); + default EventChannel getEventChannel() { + return EventChannel.DEFAULT; + } +} + +public interface EventManager { + void publish(Event event); +} +``` + +## 技术栈 + +- Java 21+ +- Spring Boot 4.0.6 +- Spring Data JPA 3.3+ +- MyBatis Plus 3.5.16 +- Spring Data MongoDB +- Spring Data Elasticsearch + +## License + +Apache License 2.0 \ No newline at end of file diff --git a/SAMPLE_MODULES.md b/SAMPLE_MODULES.md new file mode 100644 index 0000000..5584402 --- /dev/null +++ b/SAMPLE_MODULES.md @@ -0,0 +1,451 @@ +# structure-pro-infra 示例模块说明 + +本项目提供了多种数据持久化技术的示例实现,每个存储技术都有独立的子模块。 + +## 模块结构 + +``` +structure-infra-sample/ +├── structure-infra-sample-core # 核心共享模块(Entity、PO、Repository接口) +├── structure-infra-sample-mybatis # MyBatis Plus 示例模块(含低代码测试) +├── structure-infra-sample-jpa # JPA 示例模块 +├── structure-infra-sample-mongodb # MongoDB 示例模块(含 REST API、低代码测试) +├── structure-infra-sample-elasticsearch # Elasticsearch 示例模块(含 REST API、低代码测试) +└── structure-infra-sample-cqrs # CQRS 读写分离示例模块 +``` + +## 各模块说明 + +### 1. 核心共享模块(structure-infra-sample-core) + +**说明**:所有示例模块共享的核心代码,包含领域实体、持久化对象、仓储接口等。 + +**核心文件**: +- `UserEntity.java` - 用户领域实体 +- `UserPO.java` - 用户持久化对象(支持多存储注解) +- `UserRepository.java` - 用户仓储接口 +- `AbstractUserRepositoryImpl.java` - 用户仓储抽象实现 +- `UserRepositoryDelegate.java` - 用户委托接口 + +--- + +### 2. MyBatis Plus 示例(structure-infra-sample-mybatis) + +**技术栈**: +- Spring Boot 4.0.6 +- MyBatis Plus 3.5.16 +- H2 内存数据库(测试环境) + +**功能特性**: +- 完整的 CRUD 操作实现 +- 分页查询支持 +- 条件查询支持 +- Entity 与 PO 转换 +- 低代码仓储测试(MySQL 实现) + +**运行测试**: +```bash +mvn test -pl structure-infra-sample/structure-infra-sample-mybatis +``` + +**核心文件**: +- `InfraSampleApplication.java` - 启动类 +- `UserMybatisPlusDelegate.java` - MyBatis Plus 实现 +- `UserRepositoryTest.java` - 完整测试用例 +- `lowcode/LowCodeTestConfig.java` - 低代码测试配置 +- `lowcode/LowCodeRepositoryTest.java` - 低代码仓储测试 + +**状态**:✅ 已完成,测试通过 + +--- + +### 3. MongoDB 示例(structure-infra-sample-mongodb) + +**技术栈**: +- Spring Boot 4.0.6 +- Spring Data MongoDB +- MongoDB + +**功能特性**: +- 完整的 CRUD 操作实现 +- REST API 接口 +- 分页查询支持 +- 动态查询支持 +- 生产环境配置 +- 低代码仓储测试(MongoDB 实现) + +**启动服务**: +```bash +mvn spring-boot:run -pl structure-infra-sample/structure-infra-sample-mongodb +# 服务端口:8081 +``` + +**REST API 接口**: + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/users` | 创建用户 | +| GET | `/api/users/{id}` | 根据ID查询用户 | +| GET | `/api/users/name/{username}` | 根据用户名查询 | +| GET | `/api/users/list` | 查询全部用户列表 | +| GET | `/api/users/page?page=1&size=10` | 分页查询 | +| PUT | `/api/users/{id}` | 更新用户 | +| DELETE | `/api/users/{id}` | 删除用户 | +| POST | `/api/users/batch` | 批量创建用户 | +| GET | `/api/users/count` | 查询用户总数 | + +**请求示例**: +```bash +# 创建用户 +curl -X POST http://localhost:8081/api/users \ + -H "Content-Type: application/json" \ + -d '{"username":"zhangsan","email":"zhangsan@example.com","age":25}' + +# 查询用户列表 +curl http://localhost:8081/api/users/list + +# 分页查询 +curl "http://localhost:8081/api/users/page?page=1&size=10" +``` + +**核心文件**: +- `MongoSampleApplication.java` - 启动类 +- `MongoConfig.java` - MongoDB 配置类 +- `UserController.java` - REST API 控制器 +- `UserMongoRepositoryImpl.java` - 仓储实现 +- `MockMongoConfiguration.java` - 测试 Mock 配置 +- `UserMongoRepositoryTest.java` - 测试用例 +- `lowcode/MongoLowCodeRepositoryTest.java` - 低代码仓储测试(通过 LowCodeRepository 接口) + +**配置文件**: +- `application.yml` - 生产配置 +- `application-mongo-test.yml` - 测试配置 + +**状态**:✅ 已完成,测试通过 + +--- + +### 4. Elasticsearch 示例(structure-infra-sample-elasticsearch) + +**技术栈**: +- Spring Boot 4.0.6 +- Spring Data Elasticsearch +- Elasticsearch 8.x + +**功能特性**: +- 完整的 CRUD 操作实现 +- REST API 接口 +- 分页查询支持 +- 全文搜索支持 +- 生产环境配置 +- 低代码仓储测试(Elasticsearch 实现) + +**启动服务**: +```bash +mvn spring-boot:run -pl structure-infra-sample/structure-infra-sample-elasticsearch +# 服务端口:8082 +``` + +**REST API 接口**: + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/users` | 创建用户 | +| GET | `/api/users/{id}` | 根据ID查询用户 | +| GET | `/api/users/name/{username}` | 根据用户名查询 | +| GET | `/api/users/list` | 查询全部用户列表 | +| GET | `/api/users/page?page=1&size=10` | 分页查询 | +| PUT | `/api/users/{id}` | 更新用户 | +| DELETE | `/api/users/{id}` | 删除用户 | +| POST | `/api/users/batch` | 批量创建用户 | +| GET | `/api/users/count` | 查询用户总数 | + +**请求示例**: +```bash +# 创建用户 +curl -X POST http://localhost:8082/api/users \ + -H "Content-Type: application/json" \ + -d '{"username":"zhangsan","email":"zhangsan@example.com","age":25}' + +# 查询用户列表 +curl http://localhost:8082/api/users/list + +# 分页查询 +curl "http://localhost:8082/api/users/page?page=1&size=10" +``` + +**核心文件**: +- `ElasticsearchSampleApplication.java` - 启动类 +- `ElasticsearchConfig.java` - Elasticsearch 配置类 +- `UserController.java` - REST API 控制器 +- `UserElasticsearchRepositoryImpl.java` - 仓储实现 +- `MockElasticsearchConfiguration.java` - 测试 Mock 配置 +- `UserElasticsearchRepositoryTest.java` - 测试用例 +- `lowcode/ElasticsearchLowCodeRepositoryTest.java` - 低代码仓储测试(通过 LowCodeRepository 接口) + +**配置文件**: +- `application.yml` - 生产配置(支持从配置文件读取 ES 连接信息) +- `application-es-test.yml` - 测试配置 + +**状态**:✅ 已完成,测试通过 + +--- + +### 5. JPA 示例(structure-infra-sample-jpa) + +**技术栈**: +- Spring Data JPA +- Hibernate +- H2 内存数据库(测试环境) + +**功能特性**: +- JPA Repository 实现 +- Entity 管理 +- 事务管理 + +**运行测试**: +```bash +mvn test -pl structure-infra-sample/structure-infra-sample-jpa +``` + +**核心文件**: +- `UserJpaRepositoryTest.java` - 测试用例 +- `application-jpa-test.yml` - 测试配置 + +**状态**:基础实现已完成 + +--- + +### 6. CQRS 示例(structure-infra-sample-cqrs) + +**技术栈**: +- MyBatis Plus(写操作) +- Elasticsearch(读操作) +- H2 内存数据库 + +**功能特性**: +- 读写分离模式演示 +- 写操作走 MyBatis Plus +- 读操作走 Elasticsearch +- 读失败自动回退到写代理 + +**运行测试**: +```bash +mvn test -pl structure-infra-sample/structure-infra-sample-cqrs +``` + +**核心文件**: +- `CqrsApplication.java` - 启动类 +- `UserCqrsRepositoryTest.java` - CQRS 测试用例 + +**状态**:基础实现已完成 + +--- + +## 低代码仓储测试 + +低代码仓储是一套无需定义实体类的动态数据访问方案,通过 `LowCodeRepository` 接口统一操作不同存储引擎。 + +### 测试覆盖 + +所有低代码测试均通过 `LowCodeRepository` 接口进行,验证完整的路由机制: + +| 方法 | 说明 | MySQL | MongoDB | Elasticsearch | +|------|------|:-----:|:-------:|:-------------:| +| `save` | 新增和更新 | ✅ | ✅ | ✅ | +| `findById` | 根据 ID 查询 | ✅ | ✅ | ✅ | +| `queryById` | 根据 ID 查询(读操作) | ✅ | ✅ | ✅ | +| `queryByIdOptional` | 根据 ID 查询(Optional) | ✅ | ✅ | ✅ | +| `queryOne` | 条件查询单条 | ✅ | ✅ | ✅ | +| `queryOneOptional` | 条件查询单条(Optional) | ✅ | ✅ | ✅ | +| `queryList` | 条件查询列表 | ✅ | ✅ | ✅ | +| `queryPage` | 分页查询 | ✅ | ✅ | ✅ | +| `removeById` | 根据 ID 删除 | ✅ | ✅ | ✅ | +| `saveBatch` | 批量保存 | ✅ | ✅ | ✅ | +| `removeBatchByIds` | 批量删除 | ✅ | ✅ | ✅ | +| `listByIds` | 批量查询 | ✅ | ✅ | ✅ | +| `count` | 统计数量 | ✅ | ✅ | ✅ | +| `exists` | 判断存在 | ✅ | ✅ | ✅ | +| `testAutoFill` | 自动填充时间字段 | ✅ | ✅ | ✅ | + +### 运行低代码测试 + +```bash +# MySQL 低代码测试 +mvn test -pl structure-infra-sample/structure-infra-sample-mybatis \ + -Dtest="cn.structure.infra.sample.lowcode.LowCodeRepositoryTest" + +# MongoDB 低代码测试 +mvn test -pl structure-infra-sample/structure-infra-sample-mongodb \ + -Dtest="cn.structure.infra.sample.mongodb.lowcode.MongoLowCodeRepositoryTest" + +# Elasticsearch 低代码测试 +mvn test -pl structure-infra-sample/structure-infra-sample-elasticsearch \ + -Dtest="cn.structure.infra.sample.elasticsearch.lowcode.ElasticsearchLowCodeRepositoryTest" +``` + +### 测试策略 + +低代码测试采用纯单元测试方式,使用 Mockito 直接模拟底层存储模板: + +- **MySQL**:模拟 MyBatis Plus 的 Mapper,使用内存 Map 存储数据 +- **MongoDB**:模拟 MongoTemplate,使用内存 Map 模拟集合,支持 Query 条件解析 +- **Elasticsearch**:模拟 ElasticsearchOperations,使用内存 Map 模拟索引,支持 Criteria 条件解析 + +--- + +## 测试说明 + +### Mock 测试配置 + +MongoDB 和 Elasticsearch 示例模块提供了 Mock 配置,用于在没有真实数据库服务的情况下运行测试: + +**MongoDB Mock**: +- `MockMongoConfiguration.java` - 用 HashMap 模拟 MongoDB 存储 +- 支持 save、findById、find、remove、count 等操作 +- 支持 Query 条件过滤 + +**Elasticsearch Mock**: +- `MockElasticsearchConfiguration.java` - 用 HashMap 模拟 ES 存储 +- 支持 save、get、search、delete、count 等操作 +- 支持分页查询 + +### 运行所有测试 + +```bash +# MyBatis Plus +mvn test -pl structure-infra-sample/structure-infra-sample-mybatis + +# MongoDB(使用 Mock) +mvn test -pl structure-infra-sample/structure-infra-sample-mongodb + +# Elasticsearch(使用 Mock) +mvn test -pl structure-infra-sample/structure-infra-sample-elasticsearch + +# JPA +mvn test -pl structure-infra-sample/structure-infra-sample-jpa + +# CQRS +mvn test -pl structure-infra-sample/structure-infra-sample-cqrs +``` + +--- + +## 配置说明 + +### MongoDB 配置(application.yml) + +```yaml +server: + port: 8081 + +structure: + infra: + type: MONGODB + +spring: + data: + mongodb: + uri: mongodb://user:password@host:27017/database?authSource=admin +``` + +### Elasticsearch 配置(application.yml) + +```yaml +server: + port: 8082 + +structure: + infra: + type: ELASTICSEARCH + +spring: + elasticsearch: + uris: http://host:9200 + username: elastic + password: your_password +``` + +--- + +## Delegate 模式说明 + +每个存储技术都通过实现 `UserRepositoryDelegate` 接口来提供数据访问能力: + +```java +@Repository( + value = "用户仓储", + type = RepositoryType.MONGODB, // 指定存储类型 + entity = UserEntity.class, + po = UserPO.class +) +@Component("userRepository") +public class UserMongoRepositoryImpl extends AbstractUserRepositoryImpl { + // 继承基类,自动获得 CRUD 能力 +} +``` + +### RepositoryType 枚举 + +| 类型 | 说明 | +|------|------| +| `MYBATIS_PLUS` | MyBatis Plus 实现 | +| `JPA` | JPA 实现 | +| `MONGODB` | MongoDB 实现 | +| `ELASTICSEARCH` | Elasticsearch 实现 | +| `AUTO` | 自动选择 | + +--- + +## 依赖关系 + +``` +structure-infra-sample-core(共享模块) + ├── structure-infra-starter + └── structure-common + +structure-infra-sample-mybatis + ├── structure-infra-sample-core + ├── structure-infra-mybatis-plus-starter + └── structure-common + +structure-infra-sample-mongodb + ├── structure-infra-sample-core + ├── structure-infra-mongodb-starter + ├── spring-boot-starter-web + └── structure-common + +structure-infra-sample-elasticsearch + ├── structure-infra-sample-core + ├── structure-infra-elasticsearch-starter + ├── spring-boot-starter-web + └── structure-common + +structure-infra-sample-jpa + ├── structure-infra-sample-core + ├── structure-infra-jpa-starter + └── structure-common + +structure-infra-sample-cqrs + ├── structure-infra-sample-core + ├── structure-infra-mybatis-plus-starter + ├── structure-infra-elasticsearch-starter + └── structure-common +``` + +--- + +## 注意事项 + +1. **Mock 测试**:MongoDB 和 Elasticsearch 模块提供了 Mock 配置,测试无需真实数据库服务 +2. **生产启动**:启动 MongoDB/ES 示例服务需要相应的数据库服务运行 +3. **配置读取**:ES 配置类会自动从 application.yml 读取连接信息 +4. **依赖隔离**:每个示例模块都排除了其他存储技术的依赖,避免冲突 +5. **代码复用**:所有模块共享 core 模块中的 Entity、PO、Repository 接口 +6. **低代码测试**:低代码测试使用纯单元测试方式,不依赖 Spring 上下文,执行速度更快 + +--- + +## 许可证 + +本项目遵循 Apache License 2.0 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8ab93df --- /dev/null +++ b/pom.xml @@ -0,0 +1,151 @@ + + + 4.0.0 + + + cn.structured + structure-dependencies + 1.4.0 + + + + structure-pro-infra + structure-pro-infra + ${revision} + pom + pro项目父工程 + + + 1.0.0-SNAPSHOT + 4.0.6 + 3.5.16 + 1.4.3-SNAPSHOT + 1.1.4 + 1.0.3 + + + + structure-infra-starter + structure-infra-mybatis-plus-starter + structure-infra-jpa-starter + structure-infra-mongodb-starter + structure-infra-elasticsearch-starter + structure-infra-sample + + + + + + + + cn.structured + structure-common + ${structure.version} + + + + cn.structured + structure-mybatis-plus-starter + ${structure.version} + + + + cn.structured + structure-restful-web-starter + ${structure.version} + + + + cn.structured + structure-tenant-starter + ${structure.version} + + + + + com.baomidou + mybatis-plus-spring-boot4-starter + ${mybatis-plus.version} + + + + com.baomidou + mybatis-plus-jsqlparser + ${mybatis-plus.version} + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + cn.structured + structure-security-core + ${structure-security.version} + + + + cn.structured + structure-security-permission-starter + ${structure-security.version} + + + cn.structured + structure-security-jwt-starter + ${structure-security.version} + + + + cn.structured + structure-datascope-starter + ${structure-datascope.version} + + + + + cn.structured + structure-datascope-message + ${structure-datascope.version} + + + + cn.structured + structure-datascope-cache + ${structure-datascope.version} + + + + + cn.structured + structure-datascope-mybatis-plus-starter + ${structure-datascope.version} + + + + + jakarta.persistence + jakarta.persistence-api + 3.2.0 + + + + cn.structured + structure-infra-starter + ${revision} + + + + cn.structured + structure-infra-mybatis-plus-starter + ${revision} + + + + + \ No newline at end of file diff --git a/structure-infra-elasticsearch-starter/pom.xml b/structure-infra-elasticsearch-starter/pom.xml new file mode 100644 index 0000000..31271af --- /dev/null +++ b/structure-infra-elasticsearch-starter/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + cn.structured + structure-pro-infra + ${revision} + ../pom.xml + + + structure-infra-elasticsearch-starter + structure-infra-elasticsearch-starter + structure-infra elasticsearch starter + jar + + + + cn.structured + structure-infra-starter + ${revision} + + + org.springframework.boot + spring-boot-starter-data-elasticsearch + + + + diff --git a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/configuration/ElasticsearchAutoConfiguration.java b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/configuration/ElasticsearchAutoConfiguration.java new file mode 100644 index 0000000..d8e638d --- /dev/null +++ b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/configuration/ElasticsearchAutoConfiguration.java @@ -0,0 +1,89 @@ +package cn.structure.infra.elasticsearch.configuration; + +import cn.structure.infra.elasticsearch.repository.ElasticsearchDelegateBeanPostProcessor; +import cn.structure.infra.elasticsearch.repository.ElasticsearchDelegateFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories; + +/** + * Elasticsearch 自动配置类 + *

+ * 当检测到 Elasticsearch 相关依赖({@link org.springframework.data.elasticsearch.core.ElasticsearchOperations})时自动配置, + * 注册 Elasticsearch 文档操作所需的核心组件,使其与仓储框架无缝集成。 + *

+ * 注册的 Bean: + *

    + *
  • {@link cn.structure.infra.elasticsearch.repository.ElasticsearchDelegateFactory} - 仓储委托工厂, + * 负责根据 PO 类创建 {@link cn.structure.infra.elasticsearch.repository.ElasticsearchRepositoryDelegate} 实例,依赖 {@link org.springframework.data.elasticsearch.core.ElasticsearchOperations}
  • + *
  • {@link cn.structure.infra.elasticsearch.repository.ElasticsearchDelegateBeanPostProcessor} - Bean 后处理器, + * 为自定义的 ElasticsearchRepositoryDelegate 实现类自动注入 ElasticsearchOperations 和实体类
  • + *
+ *

+ * 工作机制: + *

    + *
  1. 当 {@link cn.structure.infra.repository.RepositoryFacade} 需要获取 RepositoryDelegate 时, + * 会通过 {@link cn.structure.infra.repository.RepositoryBeanPostProcessor} 查找匹配的 Delegate
  2. + *
  3. 若未找到用户自定义的 Delegate,会通过 ElasticsearchDelegateFactory 自动创建
  4. + *
  5. DelegateBeanPostProcessor 确保用户自定义的 Delegate 实现能正确注入 ElasticsearchOperations
  6. + *
+ *

+ * 配置方式: + *

    + *
  • 默认自动启用(matchIfMissing = true)
  • + *
  • 可通过 `structure.infra.type=ELASTICSEARCH` 显式指定
  • + *
+ *

+ * 额外配置: + *

    + *
  • {@link EnableElasticsearchRepositories} - 启用 Spring Data Elasticsearch 仓库扫描
  • + *
+ * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@AutoConfiguration +@ConditionalOnClass(name = "org.springframework.data.elasticsearch.core.ElasticsearchOperations") +@ConditionalOnProperty(prefix = "structure.infra", name = "type", havingValue = "ELASTICSEARCH", matchIfMissing = true) +@EnableElasticsearchRepositories +public class ElasticsearchAutoConfiguration { + + /** + * 创建 Elasticsearch 仓储委托工厂 + *

+ * 负责根据 PO 类创建 ElasticsearchRepositoryDelegate 实例,通过 ElasticsearchOperations 进行文档操作。 + * 当 RepositoryFacade 需要获取 Elasticsearch 类型的 RepositoryDelegate 时,会通过此工厂进行创建。 + * + * @param elasticsearchOperations Elasticsearch 操作模板,用于执行索引、查询等操作 + * @return ElasticsearchDelegateFactory 实例 + */ + @Bean + @ConditionalOnBean(ElasticsearchOperations.class) + public ElasticsearchDelegateFactory elasticsearchDelegateFactory(ElasticsearchOperations elasticsearchOperations) { + return new ElasticsearchDelegateFactory(elasticsearchOperations); + } + + /** + * 创建 Elasticsearch 委托 Bean 后处理器 + *

+ * 在 Bean 初始化完成后,自动为 ElasticsearchRepositoryDelegate 实现类注入 ElasticsearchOperations 和实体类。 + *

+ * 处理逻辑: + * 1. 扫描所有 Bean,筛选出 ElasticsearchRepositoryDelegate 的实例 + * 2. 从 Spring 上下文获取 ElasticsearchOperations 并注入到 Delegate 实例中 + * 3. 检查是否存在 {@link cn.structure.infra.annotations.DelegateFor} 注解 + * 4. 将注解中指定的 PO 类设置到 Delegate 实例中 + * + * @return ElasticsearchDelegateBeanPostProcessor 实例 + */ + @Bean + @ConditionalOnBean(ElasticsearchOperations.class) + public ElasticsearchDelegateBeanPostProcessor elasticsearchDelegateBeanPostProcessor() { + return new ElasticsearchDelegateBeanPostProcessor(); + } +} diff --git a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeAutoConfiguration.java b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeAutoConfiguration.java new file mode 100644 index 0000000..2a20197 --- /dev/null +++ b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeAutoConfiguration.java @@ -0,0 +1,32 @@ +package cn.structure.infra.elasticsearch.lowcode; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; + +/** + * Elasticsearch 低代码自动配置类 + *

+ * 当低代码功能启用且存在 ElasticsearchOperations 时,自动注册 Elasticsearch 低代码仓储工厂, + * 使低代码路由引擎能够创建 Elasticsearch 类型的存储实例。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@AutoConfiguration +@ConditionalOnProperty(prefix = "structure.infra.lowcode", name = "enabled", havingValue = "true", matchIfMissing = true) +public class ElasticsearchLowCodeAutoConfiguration { + + /** + * 注册 Elasticsearch 低代码仓储工厂 + * + * @param elasticsearchOperations ElasticsearchOperations 实例 + * @return Elasticsearch 低代码仓储工厂实例 + */ + @Bean + public ElasticsearchLowCodeRepoFactory elasticsearchLowCodeRepoFactory(ElasticsearchOperations elasticsearchOperations) { + return new ElasticsearchLowCodeRepoFactory(elasticsearchOperations); + } +} \ No newline at end of file diff --git a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeRepoFactory.java b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeRepoFactory.java new file mode 100644 index 0000000..6f1bb4b --- /dev/null +++ b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeRepoFactory.java @@ -0,0 +1,48 @@ +package cn.structure.infra.elasticsearch.lowcode; + +import cn.structure.infra.lowcode.model.RepositoryConfig; +import cn.structure.infra.lowcode.model.ResourceSchema; +import cn.structure.infra.lowcode.model.StorageType; +import cn.structure.infra.lowcode.repository.LowCodeRepoFactory; +import cn.structure.infra.lowcode.repository.LowCodeStorage; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; + +/** + * Elasticsearch 低代码仓储工厂 + *

+ * 负责创建 Elasticsearch 类型的低代码存储实例,内部使用 ElasticsearchOperations + Map 执行动态操作。 + *

+ * 核心特性: + *

    + *
  • Map 动态操作:使用 Map 代替 POJO,无需定义实体类
  • + *
  • 自动创建索引:初始化时自动创建索引
  • + *
  • 自动填充:支持创建时间、更新时间自动填充
  • + *
+ * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +public class ElasticsearchLowCodeRepoFactory implements LowCodeRepoFactory { + + private final ElasticsearchOperations elasticsearchOperations; + + /** + * 通过 ElasticsearchOperations 构造 + * + * @param elasticsearchOperations ElasticsearchOperations 实例 + */ + public ElasticsearchLowCodeRepoFactory(ElasticsearchOperations elasticsearchOperations) { + this.elasticsearchOperations = elasticsearchOperations; + } + + @Override + public StorageType getType() { + return StorageType.ELASTICSEARCH; + } + + @Override + public LowCodeStorage createStorage(ResourceSchema schema, RepositoryConfig config) { + return new ElasticsearchLowCodeStorage(schema, elasticsearchOperations); + } +} \ No newline at end of file diff --git a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeStorage.java b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeStorage.java new file mode 100644 index 0000000..5a5150a --- /dev/null +++ b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/lowcode/ElasticsearchLowCodeStorage.java @@ -0,0 +1,315 @@ +package cn.structure.infra.elasticsearch.lowcode; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.lowcode.model.AutoFillType; +import cn.structure.infra.lowcode.model.FieldSchema; +import cn.structure.infra.lowcode.model.ResourceSchema; +import cn.structure.infra.lowcode.repository.LowCodeStorage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; +import org.springframework.data.elasticsearch.core.query.Criteria; +import org.springframework.data.elasticsearch.core.query.CriteriaQuery; +import org.springframework.data.elasticsearch.core.query.IndexQuery; +import org.springframework.data.elasticsearch.core.query.IndexQueryBuilder; +import org.springframework.data.elasticsearch.core.query.Query; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Elasticsearch 低代码仓储实现 + *

+ * 基于 Spring Data Elasticsearch 的低代码存储实现,使用 Map 代替 POJO 操作文档。 + *

+ * 核心特性: + *

    + *
  • Map 动态操作:使用 Map 代替 POJO,无需定义实体类
  • + *
  • 自动创建索引:初始化时自动创建索引
  • + *
  • 自动填充:支持创建时间、更新时间自动填充
  • + *
  • 动态查询:根据查询条件动态构建 Elasticsearch 查询
  • + *
  • 分页查询:支持分页查询,自动处理总数统计
  • + *
+ * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@Slf4j +public class ElasticsearchLowCodeStorage implements LowCodeStorage { + + private final ResourceSchema schema; + private final ElasticsearchOperations elasticsearchOperations; + private final IndexCoordinates indexCoordinates; + + /** + * 构造函数 + * + * @param schema 资源 schema 定义 + * @param elasticsearchOperations ElasticsearchOperations 实例 + */ + public ElasticsearchLowCodeStorage(ResourceSchema schema, ElasticsearchOperations elasticsearchOperations) { + this.schema = schema; + this.elasticsearchOperations = elasticsearchOperations; + this.indexCoordinates = IndexCoordinates.of(schema.getTableName()); + } + + @Override + public void initialize() { + String indexName = schema.getTableName(); + + // 检查索引是否存在,不存在则创建 + boolean indexExists = elasticsearchOperations.indexOps(indexCoordinates).exists(); + if (!indexExists) { + elasticsearchOperations.indexOps(indexCoordinates).create(); + log.info("Elasticsearch index created: {}", indexName); + } + + log.info("Elasticsearch lowcode storage initialized: {}", indexName); + } + + @Override + public Map save(Map data) { + Map rowData = new HashMap<>(data); + fillAutoFields(rowData, AutoFillType.CREATE); + fillAutoFields(rowData, AutoFillType.CREATE_UPDATE); + + String idField = schema.getIdFieldName(); + Object idValue = rowData.get(idField); + + if (idValue != null) { + // 更新操作 - 先检查是否存在 + Map existing = findById(idValue); + if (existing != null) { + return doUpdate(rowData); + } + } + + return doIndex(rowData); + } + + /** + * 执行索引操作(新增或更新) + * + * @param data 数据 + * @return 索引后的数据 + */ + private Map doIndex(Map data) { + String idField = schema.getIdFieldName(); + Object idValue = data.get(idField); + + IndexQuery indexQuery = new IndexQueryBuilder() + .withId(idValue != null ? String.valueOf(idValue) : null) + .withObject(data) + .build(); + + String documentId = elasticsearchOperations.index(indexQuery, indexCoordinates); + data.put(idField, documentId); + + return data; + } + + /** + * 执行更新操作 + * + * @param data 数据 + * @return 更新后的数据 + */ + private Map doUpdate(Map data) { + String idField = schema.getIdFieldName(); + Object idValue = data.get(idField); + + // 删除旧文档 + elasticsearchOperations.delete(String.valueOf(idValue), indexCoordinates); + + // 重新索引 + return doIndex(data); + } + + @Override + public void removeById(Object id) { + elasticsearchOperations.delete(String.valueOf(id), indexCoordinates); + } + + @Override + public Map findById(Object id) { + Map result = elasticsearchOperations.get(String.valueOf(id), Map.class, indexCoordinates); + return result; + } + + @Override + public Map queryById(Object id) { + return findById(id); + } + + @Override + public Map queryOne(Map queryParams) { + List> list = queryList(queryParams); + return list.isEmpty() ? null : list.get(0); + } + + @Override + public Optional> queryOneOptional(Map queryParams) { + return Optional.ofNullable(queryOne(queryParams)); + } + + @Override + public List> queryList(Map queryParams) { + Query query = buildQuery(queryParams); + SearchHits> searchHits = elasticsearchOperations.search(query, + (Class>) (Class) Map.class, indexCoordinates); + + List> results = new ArrayList<>(); + for (SearchHit> hit : searchHits.getSearchHits()) { + results.add(hit.getContent()); + } + return results; + } + + @Override + public ResPage> queryPage(ReqPage reqPage) { + int pageNum = reqPage.getPage() != null ? reqPage.getPage().intValue() - 1 : 0; + int pageSize = reqPage.getSize() != null ? reqPage.getSize().intValue() : 10; + + Query query = buildQuery(null); + PageRequest pageRequest = PageRequest.of(pageNum, pageSize, Sort.unsorted()); + query.setPageable(pageRequest); + + SearchHits> searchHits = elasticsearchOperations.search(query, + (Class>) (Class) Map.class, indexCoordinates); + + ResPage> page = new ResPage<>(); + page.setCurrent((long) pageNum + 1); + page.setSize((long) pageSize); + page.setTotal(searchHits.getTotalHits()); + + if (searchHits.getTotalHits() == 0) { + page.setRecords(new ArrayList<>()); + page.setPages(0L); + return page; + } + + long pages = (searchHits.getTotalHits() + pageSize - 1) / pageSize; + page.setPages(pages); + + List> records = new ArrayList<>(); + for (SearchHit> hit : searchHits.getSearchHits()) { + records.add(hit.getContent()); + } + page.setRecords(records); + + return page; + } + + @Override + public List> saveBatch(List> dataList) { + if (dataList == null || dataList.isEmpty()) { + return new ArrayList<>(); + } + + List> result = new ArrayList<>(); + for (Map data : dataList) { + result.add(save(data)); + } + return result; + } + + @Override + public void removeBatchByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return; + } + for (Object id : ids) { + elasticsearchOperations.delete(String.valueOf(id), indexCoordinates); + } + } + + @Override + public List> listByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return new ArrayList<>(); + } + + List> results = new ArrayList<>(); + for (Object id : ids) { + Map doc = findById(id); + if (doc != null) { + results.add(doc); + } + } + return results; + } + + @Override + public long count(Map queryParams) { + Query query = buildQuery(queryParams); + return elasticsearchOperations.count(query, indexCoordinates); + } + + @Override + public boolean exists(Map queryParams) { + return count(queryParams) > 0; + } + + /** + * 构建 Elasticsearch 查询 + * + * @param queryParams 查询参数 + * @return Elasticsearch Query 对象 + */ + private Query buildQuery(Map queryParams) { + Criteria criteria = new Criteria(); + + if (queryParams != null && !queryParams.isEmpty()) { + boolean first = true; + for (Map.Entry entry : queryParams.entrySet()) { + String fieldName = entry.getKey(); + if (schema.getField(fieldName) != null && entry.getValue() != null) { + if (first) { + criteria = Criteria.where(fieldName).is(entry.getValue()); + first = false; + } else { + criteria = criteria.and(Criteria.where(fieldName).is(entry.getValue())); + } + } + } + } else { + // 查询所有 + criteria = Criteria.where("_id").exists(); + } + + return new CriteriaQuery(criteria); + } + + /** + * 填充自动字段 + * + * @param data 数据 + * @param fillType 填充类型 + */ + private void fillAutoFields(Map data, AutoFillType fillType) { + LocalDateTime now = LocalDateTime.now(); + for (FieldSchema field : schema.getFields().values()) { + if (field.getAutoFill() == fillType) { + String name = field.getName(); + if (!data.containsKey(name)) { + switch (field.getType()) { + case DATETIME -> data.put(name, now); + case DATE -> data.put(name, now.toLocalDate()); + default -> { + } + } + } + } + } + } +} \ No newline at end of file diff --git a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchDelegateBeanPostProcessor.java b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchDelegateBeanPostProcessor.java new file mode 100644 index 0000000..e06fa06 --- /dev/null +++ b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchDelegateBeanPostProcessor.java @@ -0,0 +1,42 @@ +package cn.structure.infra.elasticsearch.repository; + +import cn.structure.infra.annotations.DelegateFor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; + +@Slf4j +public class ElasticsearchDelegateBeanPostProcessor implements BeanPostProcessor, ApplicationContextAware { + + private ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof ElasticsearchRepositoryDelegate) { + ElasticsearchRepositoryDelegate delegate = (ElasticsearchRepositoryDelegate) bean; + try { + ElasticsearchOperations elasticsearchOperations = applicationContext.getBean(ElasticsearchOperations.class); + delegate.setElasticsearchOperations(elasticsearchOperations); + + DelegateFor annotation = bean.getClass().getAnnotation(DelegateFor.class); + if (annotation != null && annotation.po() != void.class) { + delegate.setEntityClass(annotation.po()); + } + + log.info("Injected ElasticsearchOperations into ElasticsearchRepositoryDelegate: {}", beanName); + } catch (Exception e) { + log.warn("Failed to inject ElasticsearchOperations into ElasticsearchRepositoryDelegate {}: {}", beanName, e.getMessage()); + } + } + return bean; + } +} \ No newline at end of file diff --git a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchDelegateFactory.java b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchDelegateFactory.java new file mode 100644 index 0000000..103fa64 --- /dev/null +++ b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchDelegateFactory.java @@ -0,0 +1,39 @@ +package cn.structure.infra.elasticsearch.repository; + +import cn.structure.infra.repository.RepositoryDelegate; +import cn.structure.infra.repository.RepositoryDelegateFactory; +import cn.structure.infra.repository.RepositoryType; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; + +/** + * Elasticsearch 仓储委托工厂 + *

+ * 自动创建 ElasticsearchRepositoryDelegate 实例 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +public class ElasticsearchDelegateFactory implements RepositoryDelegateFactory { + + private final ElasticsearchOperations elasticsearchOperations; + + public ElasticsearchDelegateFactory(ElasticsearchOperations elasticsearchOperations) { + this.elasticsearchOperations = elasticsearchOperations; + } + + @Override + public RepositoryType getType() { + return RepositoryType.ELASTICSEARCH; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public RepositoryDelegate createDelegate(Class poClass, Class idClass) { + try { + return new ElasticsearchRepositoryDelegate(elasticsearchOperations, poClass); + } catch (Exception e) { + return null; + } + } +} diff --git a/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchRepositoryDelegate.java b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchRepositoryDelegate.java new file mode 100644 index 0000000..3d0a1fe --- /dev/null +++ b/structure-infra-elasticsearch-starter/src/main/java/cn/structure/infra/elasticsearch/repository/ElasticsearchRepositoryDelegate.java @@ -0,0 +1,227 @@ +package cn.structure.infra.elasticsearch.repository; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.repository.RepositoryDelegate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.data.elasticsearch.core.query.Criteria; +import org.springframework.data.elasticsearch.core.query.CriteriaQuery; +import org.springframework.data.elasticsearch.core.query.Query; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Elasticsearch 仓储委托实现 + *

+ * 基于 Spring Data Elasticsearch 实现的仓储委托 + * + * @param 持久化对象类型(PO) + * @param 主键类型 + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@Slf4j +public class ElasticsearchRepositoryDelegate implements RepositoryDelegate { + + protected ElasticsearchOperations elasticsearchOperations; + protected Class entityClass; + protected String idFieldName; + + public ElasticsearchRepositoryDelegate() { + } + + public ElasticsearchRepositoryDelegate(ElasticsearchOperations elasticsearchOperations, Class entityClass) { + this(elasticsearchOperations, entityClass, "id"); + } + + public ElasticsearchRepositoryDelegate(ElasticsearchOperations elasticsearchOperations, Class entityClass, String idFieldName) { + this.elasticsearchOperations = elasticsearchOperations; + this.entityClass = entityClass; + this.idFieldName = idFieldName; + log.info("ElasticsearchRepositoryDelegate initialized for entity: {}", entityClass.getSimpleName()); + } + + public void setElasticsearchOperations(ElasticsearchOperations elasticsearchOperations) { + this.elasticsearchOperations = elasticsearchOperations; + } + + public void setEntityClass(Class entityClass) { + this.entityClass = entityClass; + } + + public void setIdFieldName(String idFieldName) { + this.idFieldName = idFieldName; + } + + @Override + public T save(T entity) { + if (entity == null) { + return null; + } + T saved = elasticsearchOperations.save(entity); + log.debug("Saved entity: {}", saved); + return saved; + } + + @Override + public void removeById(ID id) { + if (id != null) { + elasticsearchOperations.delete(String.valueOf(id), entityClass); + log.debug("Removed entity: id={}", id); + } + } + + @Override + public T findById(ID id) { + if (id == null) { + return null; + } + T entity = elasticsearchOperations.get(String.valueOf(id), entityClass); + log.debug("Find by id: id={}, found={}", id, entity != null); + return entity; + } + + @Override + public T queryById(ID id) { + return findById(id); + } + + @Override + public Optional queryByIdOptional(ID id) { + return Optional.ofNullable(queryById(id)); + } + + @Override + public T queryOne(T condition) { + if (condition == null) { + return null; + } + Query query = buildQuery(condition); + SearchHits searchHits = elasticsearchOperations.search(query, entityClass); + return searchHits.hasSearchHits() ? searchHits.getSearchHit(0).getContent() : null; + } + + @Override + public Optional queryOneOptional(T condition) { + return Optional.ofNullable(queryOne(condition)); + } + + @Override + public List queryList(T condition) { + if (condition == null) { + Query query = new CriteriaQuery(Criteria.where("*").exists()); + SearchHits searchHits = elasticsearchOperations.search(query, entityClass); + return searchHits.getSearchHits().stream() + .map(hit -> hit.getContent()) + .collect(Collectors.toList()); + } + Query query = buildQuery(condition); + SearchHits searchHits = elasticsearchOperations.search(query, entityClass); + return searchHits.getSearchHits().stream() + .map(hit -> hit.getContent()) + .collect(Collectors.toList()); + } + + @Override + public ResPage queryPage(ReqPage reqPage) { + int pageNum = reqPage.getPage() != null ? reqPage.getPage() - 1 : 0; + int pageSize = reqPage.getSize() != null ? reqPage.getSize() : 10; + + Query query = new CriteriaQuery(Criteria.where("*").exists()); + PageRequest pageRequest = PageRequest.of(pageNum, pageSize, Sort.unsorted()); + query.setPageable(pageRequest); + + SearchHits searchHits = elasticsearchOperations.search(query, entityClass); + + ResPage resPage = new ResPage<>(); + resPage.setCurrent((long) (pageNum + 1)); + resPage.setPages((long) (searchHits.getTotalHits() > 0 ? (searchHits.getTotalHits() + pageSize - 1) / pageSize : 0)); + resPage.setSize((long) pageSize); + resPage.setTotal(searchHits.getTotalHits()); + resPage.setRecords(searchHits.getSearchHits().stream() + .map(hit -> hit.getContent()) + .collect(Collectors.toList())); + + log.debug("Query page: page={}, size={}, total={}, records={}", + pageNum + 1, pageSize, searchHits.getTotalHits(), resPage.getRecords().size()); + return resPage; + } + + private Query buildQuery(T condition) { + Criteria criteria = new Criteria(); + try { + Field[] fields = getAllFields(condition.getClass()); + for (Field field : fields) { + field.setAccessible(true); + Object value = field.get(condition); + if (value != null) { + criteria = criteria.and(Criteria.where(field.getName()).is(value)); + } + } + } catch (Exception e) { + log.warn("Error building query: {}", e.getMessage()); + } + return new CriteriaQuery(criteria); + } + + private Field[] getAllFields(Class clazz) { + List fields = new java.util.ArrayList<>(); + while (clazz != null && clazz != Object.class) { + fields.addAll(Arrays.asList(clazz.getDeclaredFields())); + clazz = clazz.getSuperclass(); + } + return fields.toArray(new Field[0]); + } + + @Override + public List saveBatch(List entities) { + if (entities == null || entities.isEmpty()) { + return List.of(); + } + return entities.stream() + .map(elasticsearchOperations::save) + .collect(Collectors.toList()); + } + + @Override + public void removeBatchByIds(List ids) { + if (ids != null && !ids.isEmpty()) { + ids.forEach(id -> elasticsearchOperations.delete(String.valueOf(id), entityClass)); + } + } + + @Override + public List listByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + return ids.stream() + .map(this::findById) + .filter(entity -> entity != null) + .collect(Collectors.toList()); + } + + @Override + public long count(T condition) { + if (condition == null) { + Query query = new CriteriaQuery(Criteria.where("*").exists()); + return elasticsearchOperations.count(query, entityClass); + } + Query query = buildQuery(condition); + return elasticsearchOperations.count(query, entityClass); + } + + @Override + public boolean exists(T condition) { + return count(condition) > 0; + } +} diff --git a/structure-infra-elasticsearch-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/structure-infra-elasticsearch-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..f0f3292 --- /dev/null +++ b/structure-infra-elasticsearch-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +cn.structure.infra.elasticsearch.configuration.ElasticsearchAutoConfiguration +cn.structure.infra.elasticsearch.lowcode.ElasticsearchLowCodeAutoConfiguration diff --git a/structure-infra-jpa-starter/pom.xml b/structure-infra-jpa-starter/pom.xml new file mode 100644 index 0000000..b04d37e --- /dev/null +++ b/structure-infra-jpa-starter/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + cn.structured + structure-pro-infra + ${revision} + ../pom.xml + + + structure-pro-jpa-starter + structure-infra-jpa-starter + structure-pro-jpa-starter + jar + + + + cn.structured + structure-infra-starter + ${revision} + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + \ No newline at end of file diff --git a/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/configuration/JpaAutoConfiguration.java b/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/configuration/JpaAutoConfiguration.java new file mode 100644 index 0000000..5ddaf2b --- /dev/null +++ b/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/configuration/JpaAutoConfiguration.java @@ -0,0 +1,85 @@ +package cn.structure.infra.jpa.configuration; + +import cn.structure.infra.jpa.repository.JpaDelegateBeanPostProcessor; +import cn.structure.infra.jpa.repository.JpaDelegateFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import jakarta.persistence.EntityManager; + +/** + * JPA 自动配置类 + *

+ * 当检测到 JPA 相关依赖({@link org.springframework.data.jpa.repository.JpaRepository})时自动配置, + * 注册 JPA 持久化所需的核心组件,使其与仓储框架无缝集成。 + *

+ * 注册的 Bean: + *

    + *
  • {@link cn.structure.infra.jpa.repository.JpaDelegateFactory} - 仓储委托工厂, + * 负责根据 PO 类创建 {@link cn.structure.infra.jpa.repository.JpaRepositoryDelegate} 实例,依赖 {@link jakarta.persistence.EntityManager}
  • + *
  • {@link cn.structure.infra.jpa.repository.JpaDelegateBeanPostProcessor} - Bean 后处理器, + * 为自定义的 JpaRepositoryDelegate 实现类自动注入 EntityManager 和实体类
  • + *
+ *

+ * 工作机制: + *

    + *
  1. 当 {@link cn.structure.infra.repository.RepositoryFacade} 需要获取 RepositoryDelegate 时, + * 会通过 {@link cn.structure.infra.repository.RepositoryBeanPostProcessor} 查找匹配的 Delegate
  2. + *
  3. 若未找到用户自定义的 Delegate,会通过 JpaDelegateFactory 自动创建
  4. + *
  5. DelegateBeanPostProcessor 确保用户自定义的 Delegate 实现能正确注入 EntityManager
  6. + *
+ *

+ * 额外配置: + *

    + *
  • {@link EnableJpaRepositories} - 启用 Spring Data JPA 仓库扫描
  • + *
  • {@link EnableTransactionManagement} - 启用事务管理
  • + *
+ * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@AutoConfiguration +@ConditionalOnClass(name = "org.springframework.data.jpa.repository.JpaRepository") +@EnableJpaRepositories +@EnableTransactionManagement +public class JpaAutoConfiguration { + + /** + * 创建 JPA 仓储委托工厂 + *

+ * 负责根据 PO 类创建 JpaRepositoryDelegate 实例,通过 EntityManager 进行持久化操作。 + * 当 RepositoryFacade 需要获取 JPA 类型的 RepositoryDelegate 时,会通过此工厂进行创建。 + * + * @param entityManager JPA 实体管理器,用于执行数据库操作 + * @return JpaDelegateFactory 实例 + */ + @Bean + @ConditionalOnBean(EntityManager.class) + public JpaDelegateFactory jpaDelegateFactory(EntityManager entityManager) { + return new JpaDelegateFactory(entityManager); + } + + /** + * 创建 JPA 委托 Bean 后处理器 + *

+ * 在 Bean 初始化完成后,自动为 JpaRepositoryDelegate 实现类注入 EntityManager 和实体类。 + *

+ * 处理逻辑: + * 1. 扫描所有 Bean,筛选出 JpaRepositoryDelegate 的实例 + * 2. 从 Spring 上下文获取 EntityManager 并注入到 Delegate 实例中 + * 3. 检查是否存在 {@link cn.structure.infra.annotations.DelegateFor} 注解 + * 4. 将注解中指定的 PO 类设置到 Delegate 实例中 + * + * @return JpaDelegateBeanPostProcessor 实例 + */ + @Bean + @ConditionalOnClass(name = "jakarta.persistence.EntityManager") + public JpaDelegateBeanPostProcessor jpaDelegateBeanPostProcessor() { + return new JpaDelegateBeanPostProcessor(); + } +} diff --git a/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaDelegateBeanPostProcessor.java b/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaDelegateBeanPostProcessor.java new file mode 100644 index 0000000..76acb0f --- /dev/null +++ b/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaDelegateBeanPostProcessor.java @@ -0,0 +1,66 @@ +package cn.structure.infra.jpa.repository; + +import cn.structure.infra.annotations.DelegateFor; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; + +@Slf4j +public class JpaDelegateBeanPostProcessor implements BeanPostProcessor, ApplicationContextAware { + + private ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof JpaRepositoryDelegate) { + JpaRepositoryDelegate delegate = (JpaRepositoryDelegate) bean; + + EntityManager entityManager = getEntityManager(); + if (entityManager != null) { + delegate.setEntityManager(entityManager); + log.info("Injected EntityManager into JpaRepositoryDelegate: {}", beanName); + } else { + log.warn("No EntityManager available to inject into JpaRepositoryDelegate: {}", beanName); + } + + DelegateFor annotation = bean.getClass().getAnnotation(DelegateFor.class); + if (annotation != null && annotation.po() != void.class) { + delegate.setEntityClass(annotation.po()); + log.info("Injected entityClass {} into JpaRepositoryDelegate: {}", annotation.po().getSimpleName(), beanName); + } + } + return bean; + } + + private EntityManager getEntityManager() { + try { + Object bean = applicationContext.getBean("entityManager"); + if (bean instanceof EntityManager) { + return (EntityManager) bean; + } + } catch (Exception e) { + log.debug("entityManager bean not found by name"); + } + + try { + EntityManagerFactory factory = applicationContext.getBean(EntityManagerFactory.class); + if (factory != null) { + return factory.createEntityManager(); + } + } catch (Exception e) { + log.debug("EntityManagerFactory bean not found"); + } + + return null; + } +} \ No newline at end of file diff --git a/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaDelegateFactory.java b/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaDelegateFactory.java new file mode 100644 index 0000000..ec31282 --- /dev/null +++ b/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaDelegateFactory.java @@ -0,0 +1,40 @@ +package cn.structure.infra.jpa.repository; + +import cn.structure.infra.repository.RepositoryDelegate; +import cn.structure.infra.repository.RepositoryDelegateFactory; +import cn.structure.infra.repository.RepositoryType; + +import jakarta.persistence.EntityManager; + +/** + * JPA 仓储委托工厂 + *

+ * 自动创建 JpaRepositoryDelegate 实例 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +public class JpaDelegateFactory implements RepositoryDelegateFactory { + + private final EntityManager entityManager; + + public JpaDelegateFactory(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + public RepositoryType getType() { + return RepositoryType.JPA; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public RepositoryDelegate createDelegate(Class poClass, Class idClass) { + try { + return new JpaRepositoryDelegate(entityManager, poClass); + } catch (Exception e) { + return null; + } + } +} diff --git a/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaRepositoryDelegate.java b/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaRepositoryDelegate.java new file mode 100644 index 0000000..bfc7f5d --- /dev/null +++ b/structure-infra-jpa-starter/src/main/java/cn/structure/infra/jpa/repository/JpaRepositoryDelegate.java @@ -0,0 +1,217 @@ +package cn.structure.infra.jpa.repository; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.repository.RepositoryDelegate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +@Slf4j +public class JpaRepositoryDelegate implements RepositoryDelegate { + + protected EntityManager entityManager; + protected Class entityClass; + + public JpaRepositoryDelegate() { + } + + public JpaRepositoryDelegate(EntityManager entityManager, Class entityClass) { + this.entityManager = entityManager; + this.entityClass = entityClass; + log.info("JpaRepositoryDelegate initialized for entity: {}", entityClass.getSimpleName()); + } + + public void setEntityManager(EntityManager entityManager) { + this.entityManager = entityManager; + } + + public void setEntityClass(Class entityClass) { + this.entityClass = entityClass; + } + + @Override + public T save(T entity) { + if (entity == null || entityManager == null || entityClass == null) { + return null; + } + T saved = entityManager.merge(entity); + log.debug("Saved entity: {}", saved); + return saved; + } + + @Override + public void removeById(ID id) { + if (id != null) { + T entity = findById(id); + if (entity != null) { + entityManager.remove(entity); + log.debug("Removed entity: id={}", id); + } + } + } + + @Override + public T findById(ID id) { + if (id == null) { + return null; + } + T entity = entityManager.find(entityClass, id); + log.debug("Find by id: id={}, found={}", id, entity != null); + return entity; + } + + @Override + public T queryById(ID id) { + return findById(id); + } + + @Override + public Optional queryByIdOptional(ID id) { + return Optional.ofNullable(findById(id)); + } + + @Override + public T queryOne(T condition) { + if (condition == null) { + return null; + } + List results = queryList(condition); + return results.isEmpty() ? null : results.get(0); + } + + @Override + public Optional queryOneOptional(T condition) { + return Optional.ofNullable(queryOne(condition)); + } + + @Override + public List queryList(T condition) { + if (condition == null) { + return findAll(); + } + return queryByCondition(condition); + } + + @Override + public ResPage queryPage(ReqPage reqPage) { + int pageNum = reqPage.getPage() != null ? reqPage.getPage() - 1 : 0; + int pageSize = reqPage.getSize() != null ? reqPage.getSize() : 10; + + List allResults = findAll(); + int start = pageNum * pageSize; + int end = Math.min(start + pageSize, allResults.size()); + + List pageContent = start < allResults.size() ? allResults.subList(start, end) : List.of(); + + ResPage resPage = new ResPage<>(); + resPage.setCurrent((long) (pageNum + 1)); + resPage.setPages((long) ((allResults.size() + pageSize - 1) / pageSize)); + resPage.setSize((long) pageSize); + resPage.setTotal((long) allResults.size()); + resPage.setRecords(pageContent); + + log.debug("Query page: page={}, size={}, total={}, records={}", + pageNum + 1, pageSize, allResults.size(), pageContent.size()); + return resPage; + } + + private List findAll() { + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaQuery query = cb.createQuery(entityClass); + query.from(entityClass); + return entityManager.createQuery(query).getResultList(); + } + + private List queryByCondition(T condition) { + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaQuery query = cb.createQuery(entityClass); + Root root = query.from(entityClass); + + Predicate[] predicates = buildPredicates(cb, root, condition); + if (predicates.length > 0) { + query.where(predicates); + } + + return entityManager.createQuery(query).getResultList(); + } + + private Predicate[] buildPredicates(CriteriaBuilder cb, Root root, T condition) { + List predicates = new java.util.ArrayList<>(); + try { + Field[] fields = getAllFields(condition.getClass()); + for (Field field : fields) { + field.setAccessible(true); + Object value = field.get(condition); + if (value != null) { + predicates.add(cb.equal(root.get(field.getName()), value)); + } + } + } catch (Exception e) { + log.warn("Error building predicates: {}", e.getMessage()); + } + return predicates.toArray(new Predicate[0]); + } + + private Field[] getAllFields(Class clazz) { + List fields = new java.util.ArrayList<>(); + while (clazz != null && clazz != Object.class) { + fields.addAll(Arrays.asList(clazz.getDeclaredFields())); + clazz = clazz.getSuperclass(); + } + return fields.toArray(new Field[0]); + } + + @Override + public List saveBatch(List entities) { + if (entities == null || entities.isEmpty()) { + return List.of(); + } + return entities.stream() + .map(entityManager::merge) + .toList(); + } + + @Override + public void removeBatchByIds(List ids) { + if (ids != null) { + ids.forEach(this::removeById); + } + } + + @Override + public List listByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + return ids.stream() + .map(this::findById) + .filter(java.util.Objects::nonNull) + .toList(); + } + + @Override + public long count(T condition) { + if (condition == null) { + return findAll().size(); + } + return queryList(condition).size(); + } + + @Override + public boolean exists(T condition) { + return count(condition) > 0; + } +} \ No newline at end of file diff --git a/structure-infra-jpa-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/structure-infra-jpa-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..f03bf76 --- /dev/null +++ b/structure-infra-jpa-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.structure.infra.jpa.configuration.JpaAutoConfiguration \ No newline at end of file diff --git a/structure-infra-mongodb-starter/pom.xml b/structure-infra-mongodb-starter/pom.xml new file mode 100644 index 0000000..ba7785e --- /dev/null +++ b/structure-infra-mongodb-starter/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + + cn.structured + structure-pro-infra + ${revision} + ../pom.xml + + + structure-infra-mongodb-starter + structure-infra-mongodb-starter + structure-infra mongodb starter + jar + + + + cn.structured + structure-infra-starter + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + + diff --git a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/configuration/MongoAutoConfiguration.java b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/configuration/MongoAutoConfiguration.java new file mode 100644 index 0000000..65209b9 --- /dev/null +++ b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/configuration/MongoAutoConfiguration.java @@ -0,0 +1,89 @@ +package cn.structure.infra.mongodb.configuration; + +import cn.structure.infra.mongodb.repository.MongoDelegateBeanPostProcessor; +import cn.structure.infra.mongodb.repository.MongoDelegateFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; + +/** + * MongoDB 自动配置类 + *

+ * 当检测到 MongoDB 相关依赖({@link org.springframework.data.mongodb.core.MongoTemplate})时自动配置, + * 注册 MongoDB 文档操作所需的核心组件,使其与仓储框架无缝集成。 + *

+ * 注册的 Bean: + *

    + *
  • {@link cn.structure.infra.mongodb.repository.MongoDelegateFactory} - 仓储委托工厂, + * 负责根据 PO 类创建 {@link cn.structure.infra.mongodb.repository.MongoRepositoryDelegate} 实例,依赖 {@link org.springframework.data.mongodb.core.MongoTemplate}
  • + *
  • {@link cn.structure.infra.mongodb.repository.MongoDelegateBeanPostProcessor} - Bean 后处理器, + * 为自定义的 MongoRepositoryDelegate 实现类自动注入 MongoTemplate 和实体类
  • + *
+ *

+ * 工作机制: + *

    + *
  1. 当 {@link cn.structure.infra.repository.RepositoryFacade} 需要获取 RepositoryDelegate 时, + * 会通过 {@link cn.structure.infra.repository.RepositoryBeanPostProcessor} 查找匹配的 Delegate
  2. + *
  3. 若未找到用户自定义的 Delegate,会通过 MongoDelegateFactory 自动创建
  4. + *
  5. DelegateBeanPostProcessor 确保用户自定义的 Delegate 实现能正确注入 MongoTemplate
  6. + *
+ *

+ * 配置方式: + *

    + *
  • 默认自动启用(matchIfMissing = true)
  • + *
  • 可通过 `structure.infra.type=MONGODB` 显式指定
  • + *
+ *

+ * 额外配置: + *

    + *
  • {@link EnableMongoRepositories} - 启用 Spring Data MongoDB 仓库扫描
  • + *
+ * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@AutoConfiguration +@ConditionalOnClass(name = "org.springframework.data.mongodb.core.MongoTemplate") +@ConditionalOnProperty(prefix = "structure.infra", name = "type", havingValue = "MONGODB", matchIfMissing = true) +@EnableMongoRepositories +public class MongoAutoConfiguration { + + /** + * 创建 MongoDB 仓储委托工厂 + *

+ * 负责根据 PO 类创建 MongoRepositoryDelegate 实例,通过 MongoTemplate 进行文档操作。 + * 当 RepositoryFacade 需要获取 MongoDB 类型的 RepositoryDelegate 时,会通过此工厂进行创建。 + * + * @param mongoTemplate MongoDB 操作模板,用于执行增删改查等操作 + * @return MongoDelegateFactory 实例 + */ + @Bean + @ConditionalOnBean(MongoTemplate.class) + public MongoDelegateFactory mongoDelegateFactory(MongoTemplate mongoTemplate) { + return new MongoDelegateFactory(mongoTemplate); + } + + /** + * 创建 MongoDB 委托 Bean 后处理器 + *

+ * 在 Bean 初始化完成后,自动为 MongoRepositoryDelegate 实现类注入 MongoTemplate 和实体类。 + *

+ * 处理逻辑: + * 1. 扫描所有 Bean,筛选出 MongoRepositoryDelegate 的实例 + * 2. 从 Spring 上下文获取 MongoTemplate 并注入到 Delegate 实例中 + * 3. 检查是否存在 {@link cn.structure.infra.annotations.DelegateFor} 注解 + * 4. 将注解中指定的 PO 类设置到 Delegate 实例中 + * + * @return MongoDelegateBeanPostProcessor 实例 + */ + @Bean + @ConditionalOnBean(MongoTemplate.class) + public MongoDelegateBeanPostProcessor mongoDelegateBeanPostProcessor() { + return new MongoDelegateBeanPostProcessor(); + } +} diff --git a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeAutoConfiguration.java b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeAutoConfiguration.java new file mode 100644 index 0000000..f757124 --- /dev/null +++ b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeAutoConfiguration.java @@ -0,0 +1,32 @@ +package cn.structure.infra.mongodb.lowcode; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.data.mongodb.core.MongoTemplate; + +/** + * MongoDB 低代码自动配置类 + *

+ * 当低代码功能启用且存在 MongoTemplate 时,自动注册 MongoDB 低代码仓储工厂, + * 使低代码路由引擎能够创建 MongoDB 类型的存储实例。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@AutoConfiguration +@ConditionalOnProperty(prefix = "structure.infra.lowcode", name = "enabled", havingValue = "true", matchIfMissing = true) +public class MongoLowCodeAutoConfiguration { + + /** + * 注册 MongoDB 低代码仓储工厂 + * + * @param mongoTemplate MongoTemplate 实例 + * @return MongoDB 低代码仓储工厂实例 + */ + @Bean + public MongoLowCodeRepoFactory mongoLowCodeRepoFactory(MongoTemplate mongoTemplate) { + return new MongoLowCodeRepoFactory(mongoTemplate); + } +} \ No newline at end of file diff --git a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeRepoFactory.java b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeRepoFactory.java new file mode 100644 index 0000000..3bf2ebc --- /dev/null +++ b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeRepoFactory.java @@ -0,0 +1,48 @@ +package cn.structure.infra.mongodb.lowcode; + +import cn.structure.infra.lowcode.model.RepositoryConfig; +import cn.structure.infra.lowcode.model.ResourceSchema; +import cn.structure.infra.lowcode.model.StorageType; +import cn.structure.infra.lowcode.repository.LowCodeRepoFactory; +import cn.structure.infra.lowcode.repository.LowCodeStorage; +import org.springframework.data.mongodb.core.MongoTemplate; + +/** + * MongoDB 低代码仓储工厂 + *

+ * 负责创建 MongoDB 类型的低代码存储实例,内部使用 MongoTemplate + Document 执行动态操作。 + *

+ * 核心特性: + *

    + *
  • Document 动态操作:使用 Document 代替 POJO,无需定义实体类
  • + *
  • 自动创建集合:初始化时自动创建集合和索引
  • + *
  • 自动填充:支持创建时间、更新时间自动填充
  • + *
+ * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +public class MongoLowCodeRepoFactory implements LowCodeRepoFactory { + + private final MongoTemplate mongoTemplate; + + /** + * 通过 MongoTemplate 构造 + * + * @param mongoTemplate MongoTemplate 实例 + */ + public MongoLowCodeRepoFactory(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @Override + public StorageType getType() { + return StorageType.MONGODB; + } + + @Override + public LowCodeStorage createStorage(ResourceSchema schema, RepositoryConfig config) { + return new MongoLowCodeStorage(schema, mongoTemplate); + } +} \ No newline at end of file diff --git a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeStorage.java b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeStorage.java new file mode 100644 index 0000000..ddf7ba4 --- /dev/null +++ b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/lowcode/MongoLowCodeStorage.java @@ -0,0 +1,353 @@ +package cn.structure.infra.mongodb.lowcode; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.lowcode.model.AutoFillType; +import cn.structure.infra.lowcode.model.FieldSchema; +import cn.structure.infra.lowcode.model.FieldType; +import cn.structure.infra.lowcode.model.ResourceSchema; +import cn.structure.infra.lowcode.repository.LowCodeStorage; +import lombok.extern.slf4j.Slf4j; +import org.bson.Document; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.index.Index; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * MongoDB 低代码仓储实现 + *

+ * 基于 Spring Data MongoDB 的低代码存储实现,使用 Document 代替实体类, + * 通过 MongoTemplate 动态操作 MongoDB 集合。 + *

+ * 核心特性: + *

    + *
  • Document 动态操作:使用 Document 代替 POJO,无需定义实体类
  • + *
  • 自动创建集合:初始化时自动创建集合和索引
  • + *
  • 自动填充:支持创建时间、更新时间自动填充
  • + *
  • 动态查询:根据查询条件动态构建 MongoDB 查询
  • + *
  • 分页查询:支持分页查询,自动处理总数统计
  • + *
+ * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@Slf4j +public class MongoLowCodeStorage implements LowCodeStorage { + + private final ResourceSchema schema; + private final MongoTemplate mongoTemplate; + + /** + * 构造函数 + * + * @param schema 资源 schema 定义 + * @param mongoTemplate MongoTemplate 实例 + */ + public MongoLowCodeStorage(ResourceSchema schema, MongoTemplate mongoTemplate) { + this.schema = schema; + this.mongoTemplate = mongoTemplate; + } + + @Override + public void initialize() { + String collectionName = schema.getTableName(); + + // 检查集合是否存在,不存在则创建 + boolean collectionExists = mongoTemplate.collectionExists(collectionName); + if (!collectionExists) { + mongoTemplate.createCollection(collectionName); + log.info("MongoDB collection created: {}", collectionName); + } + + // 创建索引 + createIndexes(collectionName); + + log.info("MongoDB lowcode storage initialized: {}", collectionName); + } + + /** + * 创建索引 + *

+ * 根据 schema 中的字段定义自动创建索引: + *

    + *
  • 主键字段自动创建唯一索引
  • + *
  • 标记为 index=true 的字段创建普通索引
  • + *
  • 标记为 unique=true 的字段创建唯一索引
  • + *
+ * + * @param collectionName 集合名称 + */ + private void createIndexes(String collectionName) { + for (FieldSchema field : schema.getFields().values()) { + if (field.isPrimaryKey() || field.isIndex() || field.isUnique()) { + Index index = new Index() + .on(field.getName(), field.isUnique() ? org.springframework.data.domain.Sort.Direction.ASC + : org.springframework.data.domain.Sort.Direction.ASC); + + if (field.isUnique()) { + index.unique(); + } + + mongoTemplate.indexOps(collectionName).ensureIndex(index); + log.debug("Created index for field: {} (unique={}, index={})", + field.getName(), field.isUnique(), field.isIndex()); + } + } + } + + @Override + public Map save(Map data) { + Document document = new Document(data); + fillAutoFields(document, AutoFillType.CREATE); + fillAutoFields(document, AutoFillType.CREATE_UPDATE); + + String idField = schema.getIdFieldName(); + Object idValue = document.get(idField); + + if (idValue != null) { + // 更新操作 + Query query = new Query(Criteria.where(idField).is(idValue)); + Document existing = mongoTemplate.findOne(query, Document.class, schema.getTableName()); + if (existing != null) { + return doUpdate(document); + } + } + + return doInsert(document); + } + + /** + * 执行插入操作 + * + * @param document Document 对象 + * @return 插入后的数据 + */ + private Map doInsert(Document document) { + mongoTemplate.insert(document, schema.getTableName()); + return documentToMap(document); + } + + /** + * 执行更新操作 + * + * @param document Document 对象 + * @return 更新后的数据 + */ + private Map doUpdate(Document document) { + String idField = schema.getIdFieldName(); + Object idValue = document.get(idField); + + Query query = new Query(Criteria.where(idField).is(idValue)); + + // 构建更新文档 + Update update = new Update(); + for (Map.Entry entry : document.entrySet()) { + if (!idField.equals(entry.getKey())) { + update.set(entry.getKey(), entry.getValue()); + } + } + + mongoTemplate.updateFirst(query, update, schema.getTableName()); + + return findById(idValue); + } + + @Override + public void removeById(Object id) { + Query query = new Query(Criteria.where(schema.getIdFieldName()).is(id)); + mongoTemplate.remove(query, schema.getTableName()); + } + + @Override + public Map findById(Object id) { + Query query = new Query(Criteria.where(schema.getIdFieldName()).is(id)); + Document result = mongoTemplate.findOne(query, Document.class, schema.getTableName()); + return result != null ? documentToMap(result) : null; + } + + @Override + public Map queryById(Object id) { + return findById(id); + } + + @Override + public Map queryOne(Map queryParams) { + List> list = queryList(queryParams); + return list.isEmpty() ? null : list.get(0); + } + + @Override + public Optional> queryOneOptional(Map queryParams) { + return Optional.ofNullable(queryOne(queryParams)); + } + + @Override + public List> queryList(Map queryParams) { + Query query = buildQuery(queryParams); + List results = mongoTemplate.find(query, Document.class, schema.getTableName()); + return documentsToMaps(results); + } + + @Override + public ResPage> queryPage(ReqPage reqPage) { + int pageNum = reqPage.getPage() != null ? reqPage.getPage().intValue() - 1 : 0; + int pageSize = reqPage.getSize() != null ? reqPage.getSize().intValue() : 10; + + Query query = buildQuery(null); + long total = mongoTemplate.count(query, schema.getTableName()); + + ResPage> page = new ResPage<>(); + page.setCurrent((long) pageNum + 1); + page.setSize((long) pageSize); + page.setTotal(total); + + if (total == 0) { + page.setRecords(new ArrayList<>()); + page.setPages(0L); + return page; + } + + long pages = (total + pageSize - 1) / pageSize; + page.setPages(pages); + + query.with(PageRequest.of(pageNum, pageSize, Sort.unsorted())); + List records = mongoTemplate.find(query, Document.class, schema.getTableName()); + page.setRecords(documentsToMaps(records)); + + return page; + } + + @Override + public List> saveBatch(List> dataList) { + if (dataList == null || dataList.isEmpty()) { + return new ArrayList<>(); + } + + List> result = new ArrayList<>(); + for (Map data : dataList) { + result.add(save(data)); + } + return result; + } + + @Override + public void removeBatchByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return; + } + Query query = new Query(Criteria.where(schema.getIdFieldName()).in(ids)); + mongoTemplate.remove(query, schema.getTableName()); + } + + @Override + public List> listByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return new ArrayList<>(); + } + Query query = new Query(Criteria.where(schema.getIdFieldName()).in(ids)); + List results = mongoTemplate.find(query, Document.class, schema.getTableName()); + return documentsToMaps(results); + } + + @Override + public long count(Map queryParams) { + Query query = buildQuery(queryParams); + return mongoTemplate.count(query, schema.getTableName()); + } + + @Override + public boolean exists(Map queryParams) { + return count(queryParams) > 0; + } + + /** + * 构建 MongoDB 查询 + * + * @param queryParams 查询参数 + * @return MongoDB Query 对象 + */ + private Query buildQuery(Map queryParams) { + Query query = new Query(); + + if (queryParams != null && !queryParams.isEmpty()) { + for (Map.Entry entry : queryParams.entrySet()) { + String fieldName = entry.getKey(); + if (schema.getField(fieldName) != null && entry.getValue() != null) { + Criteria criteria = Criteria.where(fieldName).is(entry.getValue()); + query.addCriteria(criteria); + } + } + } + + return query; + } + + /** + * 填充自动字段 + * + * @param document Document 对象 + * @param fillType 填充类型 + */ + private void fillAutoFields(Document document, AutoFillType fillType) { + LocalDateTime now = LocalDateTime.now(); + for (FieldSchema field : schema.getFields().values()) { + if (field.getAutoFill() == fillType) { + String name = field.getName(); + if (!document.containsKey(name)) { + switch (field.getType()) { + case DATETIME -> document.put(name, now); + case DATE -> document.put(name, now.toLocalDate()); + default -> { + } + } + } + } + } + } + + /** + * 将 Document 转换为 Map + * + * @param document Document 对象 + * @return Map 对象 + */ + private Map documentToMap(Document document) { + if (document == null) { + return null; + } + Map map = new HashMap<>(); + for (Map.Entry entry : document.entrySet()) { + map.put(entry.getKey(), entry.getValue()); + } + return map; + } + + /** + * 将 Document 列表转换为 Map 列表 + * + * @param documents Document 列表 + * @return Map 列表 + */ + private List> documentsToMaps(List documents) { + if (documents == null || documents.isEmpty()) { + return new ArrayList<>(); + } + List> result = new ArrayList<>(); + for (Document doc : documents) { + result.add(documentToMap(doc)); + } + return result; + } +} \ No newline at end of file diff --git a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoDelegateBeanPostProcessor.java b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoDelegateBeanPostProcessor.java new file mode 100644 index 0000000..35c2457 --- /dev/null +++ b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoDelegateBeanPostProcessor.java @@ -0,0 +1,42 @@ +package cn.structure.infra.mongodb.repository; + +import cn.structure.infra.annotations.DelegateFor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.data.mongodb.core.MongoTemplate; + +@Slf4j +public class MongoDelegateBeanPostProcessor implements BeanPostProcessor, ApplicationContextAware { + + private ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof MongoRepositoryDelegate) { + MongoRepositoryDelegate delegate = (MongoRepositoryDelegate) bean; + try { + MongoTemplate mongoTemplate = applicationContext.getBean(MongoTemplate.class); + delegate.setMongoTemplate(mongoTemplate); + + DelegateFor annotation = bean.getClass().getAnnotation(DelegateFor.class); + if (annotation != null && annotation.po() != void.class) { + delegate.setEntityClass(annotation.po()); + } + + log.info("Injected MongoTemplate into MongoRepositoryDelegate: {}", beanName); + } catch (Exception e) { + log.warn("Failed to inject MongoTemplate into MongoRepositoryDelegate {}: {}", beanName, e.getMessage()); + } + } + return bean; + } +} \ No newline at end of file diff --git a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoDelegateFactory.java b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoDelegateFactory.java new file mode 100644 index 0000000..645e345 --- /dev/null +++ b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoDelegateFactory.java @@ -0,0 +1,39 @@ +package cn.structure.infra.mongodb.repository; + +import cn.structure.infra.repository.RepositoryDelegate; +import cn.structure.infra.repository.RepositoryDelegateFactory; +import cn.structure.infra.repository.RepositoryType; +import org.springframework.data.mongodb.core.MongoTemplate; + +/** + * MongoDB 仓储委托工厂 + *

+ * 自动创建 MongoRepositoryDelegate 实例 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +public class MongoDelegateFactory implements RepositoryDelegateFactory { + + private final MongoTemplate mongoTemplate; + + public MongoDelegateFactory(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @Override + public RepositoryType getType() { + return RepositoryType.MONGODB; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public RepositoryDelegate createDelegate(Class poClass, Class idClass) { + try { + return new MongoRepositoryDelegate(mongoTemplate, poClass); + } catch (Exception e) { + return null; + } + } +} diff --git a/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoRepositoryDelegate.java b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoRepositoryDelegate.java new file mode 100644 index 0000000..efa7405 --- /dev/null +++ b/structure-infra-mongodb-starter/src/main/java/cn/structure/infra/mongodb/repository/MongoRepositoryDelegate.java @@ -0,0 +1,214 @@ +package cn.structure.infra.mongodb.repository; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.repository.RepositoryDelegate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +/** + * MongoDB 仓储委托实现 + *

+ * 基于 Spring Data MongoDB 实现的仓储委托 + * + * @param 持久化对象类型(PO) + * @param 主键类型 + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@Slf4j +public class MongoRepositoryDelegate implements RepositoryDelegate { + + protected MongoTemplate mongoTemplate; + protected Class entityClass; + protected String idFieldName; + + public MongoRepositoryDelegate() { + } + + public MongoRepositoryDelegate(MongoTemplate mongoTemplate, Class entityClass) { + this(mongoTemplate, entityClass, "id"); + } + + public MongoRepositoryDelegate(MongoTemplate mongoTemplate, Class entityClass, String idFieldName) { + this.mongoTemplate = mongoTemplate; + this.entityClass = entityClass; + this.idFieldName = idFieldName; + log.info("MongoRepositoryDelegate initialized for entity: {}", entityClass.getSimpleName()); + } + + public void setMongoTemplate(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + public void setEntityClass(Class entityClass) { + this.entityClass = entityClass; + } + + public void setIdFieldName(String idFieldName) { + this.idFieldName = idFieldName; + } + + @Override + public T save(T entity) { + if (entity == null) { + return null; + } + T saved = mongoTemplate.save(entity); + log.debug("Saved entity: {}", saved); + return saved; + } + + @Override + public void removeById(ID id) { + if (id != null) { + Query query = new Query(Criteria.where(idFieldName).is(id)); + mongoTemplate.remove(query, entityClass); + log.debug("Removed entity: id={}", id); + } + } + + @Override + public T findById(ID id) { + if (id == null) { + return null; + } + Query query = new Query(Criteria.where(idFieldName).is(id)); + T entity = mongoTemplate.findOne(query, entityClass); + log.debug("Find by id: id={}, found={}", id, entity != null); + return entity; + } + + @Override + public T queryById(ID id) { + return findById(id); + } + + @Override + public Optional queryByIdOptional(ID id) { + return Optional.ofNullable(queryById(id)); + } + + @Override + public T queryOne(T condition) { + if (condition == null) { + return null; + } + Query query = buildQuery(condition); + return mongoTemplate.findOne(query, entityClass); + } + + @Override + public Optional queryOneOptional(T condition) { + return Optional.ofNullable(queryOne(condition)); + } + + @Override + public List queryList(T condition) { + if (condition == null) { + return mongoTemplate.findAll(entityClass); + } + Query query = buildQuery(condition); + return mongoTemplate.find(query, entityClass); + } + + @Override + public ResPage queryPage(ReqPage reqPage) { + int pageNum = reqPage.getPage() != null ? reqPage.getPage() - 1 : 0; + int pageSize = reqPage.getSize() != null ? reqPage.getSize() : 10; + + Query query = new Query(); + long total = mongoTemplate.count(query, entityClass); + + Query pageQuery = query.with(PageRequest.of(pageNum, pageSize, Sort.unsorted())); + List records = mongoTemplate.find(pageQuery, entityClass); + + ResPage resPage = new ResPage<>(); + resPage.setCurrent((long) (pageNum + 1)); + resPage.setPages(total > 0 ? (total + pageSize - 1) / pageSize : 0); + resPage.setSize((long) pageSize); + resPage.setTotal(total); + resPage.setRecords(records); + + log.debug("Query page: page={}, size={}, total={}, records={}", + pageNum + 1, pageSize, total, records.size()); + return resPage; + } + + private Query buildQuery(T condition) { + Query query = new Query(); + try { + Field[] fields = getAllFields(condition.getClass()); + for (Field field : fields) { + field.setAccessible(true); + Object value = field.get(condition); + if (value != null) { + query.addCriteria(Criteria.where(field.getName()).is(value)); + } + } + } catch (Exception e) { + log.warn("Error building query: {}", e.getMessage()); + } + return query; + } + + private Field[] getAllFields(Class clazz) { + List fields = new java.util.ArrayList<>(); + while (clazz != null && clazz != Object.class) { + fields.addAll(Arrays.asList(clazz.getDeclaredFields())); + clazz = clazz.getSuperclass(); + } + return fields.toArray(new Field[0]); + } + + @Override + public List saveBatch(List entities) { + if (entities == null || entities.isEmpty()) { + return List.of(); + } + return entities.stream() + .map(mongoTemplate::save) + .toList(); + } + + @Override + public void removeBatchByIds(List ids) { + if (ids != null && !ids.isEmpty()) { + Query query = new Query(Criteria.where(idFieldName).in(ids)); + mongoTemplate.remove(query, entityClass); + } + } + + @Override + public List listByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + Query query = new Query(Criteria.where(idFieldName).in(ids)); + return mongoTemplate.find(query, entityClass); + } + + @Override + public long count(T condition) { + if (condition == null) { + return mongoTemplate.count(new Query(), entityClass); + } + Query query = buildQuery(condition); + return mongoTemplate.count(query, entityClass); + } + + @Override + public boolean exists(T condition) { + return count(condition) > 0; + } +} diff --git a/structure-infra-mongodb-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/structure-infra-mongodb-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..4e5ab02 --- /dev/null +++ b/structure-infra-mongodb-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +cn.structure.infra.mongodb.configuration.MongoAutoConfiguration +cn.structure.infra.mongodb.lowcode.MongoLowCodeAutoConfiguration diff --git a/structure-infra-mybatis-plus-starter/pom.xml b/structure-infra-mybatis-plus-starter/pom.xml new file mode 100644 index 0000000..060c5cd --- /dev/null +++ b/structure-infra-mybatis-plus-starter/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + cn.structured + structure-pro-infra + ${revision} + ../pom.xml + + + structure-pro-mybatis-plus-starter + structure-infra-mybatis-plus-starter + structure-pro-mybatis-plus-starter + jar + + + + cn.structured + structure-infra-starter + ${revision} + + + cn.structured + structure-mybatis-plus-starter + + + cn.structured + structure-common + + + com.baomidou + mybatis-plus-jsqlparser + + + cn.structured + structure-security-core + + + com.baomidou + mybatis-plus-spring-boot4-starter + + + cn.structured + structure-tenant-starter + + + + \ No newline at end of file diff --git a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/configuration/MybatisPlusAutoConfiguration.java b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/configuration/MybatisPlusAutoConfiguration.java new file mode 100644 index 0000000..004267d --- /dev/null +++ b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/configuration/MybatisPlusAutoConfiguration.java @@ -0,0 +1,80 @@ +package cn.structure.infra.mybatis.plus.configuration; + +import cn.structure.infra.mybatis.plus.repository.MybatisPlusDelegateBeanPostProcessor; +import cn.structure.infra.mybatis.plus.repository.MybatisPlusDelegateFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.ApplicationContext; + +/** + * MyBatis Plus 自动配置类 + *

+ * 当检测到 MyBatis Plus 相关依赖({@link com.baomidou.mybatisplus.core.mapper.BaseMapper})时自动配置, + * 注册 MyBatis Plus 持久化所需的核心组件,使其与仓储框架无缝集成。 + *

+ * 注册的 Bean: + *

    + *
  • {@link cn.structure.infra.mybatis.plus.repository.MybatisPlusDelegateFactory} - 仓储委托工厂, + * 负责根据 PO 类自动查找对应的 BaseMapper 并创建 {@link cn.structure.infra.mybatis.plus.repository.MybatisPlusRepositoryDelegate} 实例
  • + *
  • {@link cn.structure.infra.mybatis.plus.repository.MybatisPlusDelegateBeanPostProcessor} - Bean 后处理器, + * 为自定义的 MybatisPlusRepositoryDelegate 实现类自动注入 BaseMapper
  • + *
+ *

+ * 工作机制: + *

    + *
  1. 当 {@link cn.structure.infra.repository.RepositoryFacade} 需要获取 RepositoryDelegate 时, + * 会通过 {@link cn.structure.infra.repository.RepositoryBeanPostProcessor} 查找匹配的 Delegate
  2. + *
  3. 若未找到用户自定义的 Delegate,会通过 MybatisPlusDelegateFactory 自动创建
  4. + *
  5. DelegateBeanPostProcessor 确保用户自定义的 Delegate 实现能正确注入 BaseMapper
  6. + *
+ *

+ * 配置方式: + *

    + *
  • 默认自动启用(matchIfMissing = true)
  • + *
  • 可通过 `structure.infra.type=MYBATIS_PLUS` 显式指定
  • + *
+ * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@AutoConfiguration +@ConditionalOnClass(name = "com.baomidou.mybatisplus.core.mapper.BaseMapper") +@ConditionalOnProperty(prefix = "structure.infra", name = "type", havingValue = "MYBATIS_PLUS", matchIfMissing = true) +public class MybatisPlusAutoConfiguration { + + /** + * 创建 MyBatis Plus 仓储委托工厂 + *

+ * 负责根据 PO 类自动查找对应的 BaseMapper,并创建 MybatisPlusRepositoryDelegate 实例。 + * 当 RepositoryFacade 需要获取特定类型的 RepositoryDelegate 时,会通过此工厂进行创建。 + * + * @param applicationContext Spring 应用上下文,用于查找 Mapper Bean + * @return MybatisPlusDelegateFactory 实例 + */ + @Bean + public MybatisPlusDelegateFactory mybatisPlusDelegateFactory(ApplicationContext applicationContext) { + return new MybatisPlusDelegateFactory(applicationContext); + } + + /** + * 创建 MyBatis Plus 委托 Bean 后处理器 + *

+ * 在 Bean 初始化完成后,自动为带有 {@link cn.structure.infra.annotations.DelegateFor} 注解的 + * MybatisPlusRepositoryDelegate 实现类注入对应的 BaseMapper。 + *

+ * 处理逻辑: + * 1. 扫描所有 Bean,筛选出 MybatisPlusRepositoryDelegate 的实例 + * 2. 检查是否存在 {@link cn.structure.infra.annotations.DelegateFor} 注解 + * 3. 根据注解中指定的 PO 类查找对应的 BaseMapper + * 4. 将找到的 BaseMapper 注入到 Delegate 实例中 + * + * @return MybatisPlusDelegateBeanPostProcessor 实例 + */ + @Bean + public MybatisPlusDelegateBeanPostProcessor mybatisPlusDelegateBeanPostProcessor() { + return new MybatisPlusDelegateBeanPostProcessor(); + } +} diff --git a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/MySqlLowCodeRepoFactory.java b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/MySqlLowCodeRepoFactory.java new file mode 100644 index 0000000..7983c05 --- /dev/null +++ b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/MySqlLowCodeRepoFactory.java @@ -0,0 +1,48 @@ +package cn.structure.infra.mybatis.plus.lowcode; + +import cn.structure.infra.lowcode.model.RepositoryConfig; +import cn.structure.infra.lowcode.model.ResourceSchema; +import cn.structure.infra.lowcode.model.StorageType; +import cn.structure.infra.lowcode.repository.LowCodeRepoFactory; +import cn.structure.infra.lowcode.repository.LowCodeStorage; +import org.apache.ibatis.session.SqlSessionFactory; + +/** + * MySQL 低代码仓储工厂 + *

+ * 负责创建 MySQL 类型的低代码存储实例,内部使用 MyBatis SqlSession 执行动态 SQL。 + *

+ * 核心特性: + *

    + *
  • 拦截器支持:通过动态注册 MappedStatement,SQL 经过 MyBatis 拦截器链
  • + *
  • 多方言适配:支持 MySQL、H2、Oracle、PostgreSQL、SQL Server 自动检测
  • + *
  • 性能优化:明确列名查询替代 SELECT *,方言适配分页语法
  • + *
+ * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +public class MySqlLowCodeRepoFactory implements LowCodeRepoFactory { + + private final SqlSessionFactory sqlSessionFactory; + + /** + * 通过 SqlSessionFactory 构造 + * + * @param sqlSessionFactory MyBatis SqlSessionFactory + */ + public MySqlLowCodeRepoFactory(SqlSessionFactory sqlSessionFactory) { + this.sqlSessionFactory = sqlSessionFactory; + } + + @Override + public StorageType getType() { + return StorageType.MYSQL; + } + + @Override + public LowCodeStorage createStorage(ResourceSchema schema, RepositoryConfig config) { + return new MySqlLowCodeStorage(schema, sqlSessionFactory); + } +} \ No newline at end of file diff --git a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/MySqlLowCodeStorage.java b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/MySqlLowCodeStorage.java new file mode 100644 index 0000000..5c0b453 --- /dev/null +++ b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/MySqlLowCodeStorage.java @@ -0,0 +1,833 @@ +package cn.structure.infra.mybatis.plus.lowcode; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.lowcode.model.AutoFillType; +import cn.structure.infra.lowcode.model.FieldSchema; +import cn.structure.infra.lowcode.model.FieldType; +import cn.structure.infra.lowcode.model.ResourceSchema; +import cn.structure.infra.lowcode.repository.LowCodeStorage; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.mapping.ResultMap; +import org.apache.ibatis.mapping.ResultMapping; +import org.apache.ibatis.mapping.SqlCommandType; +import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +/** + * MySQL/H2 低代码仓储实现(MyBatis SqlSession 版) + *

+ * 基于 MyBatis SqlSession 的关系型数据库低代码存储实现, + * 通过动态注册 MappedStatement 的方式执行 SQL, + * 确保 SQL 能够经过 MyBatis 拦截器链(分页插件、数据权限、SQL 监控等)。 + *

+ * 核心特性: + *

    + *
  • 拦截器支持:SQL 经过 MyBatis 拦截器链,兼容所有 MyBatis 插件
  • + *
  • 明确列名查询:使用 schema 中的字段列表替代 SELECT *,提升性能
  • + *
  • 多方言适配:建表、分页等 SQL 根据数据库类型自动适配
  • + *
  • 自动建表:根据 ResourceSchema 自动生成 DDL 语句
  • + *
  • 主键自增:支持自增主键,插入后自动回填 ID
  • + *
  • 自动填充:支持创建时间、更新时间自动填充
  • + *
  • 动态 SQL:根据查询条件动态生成 WHERE 子句
  • + *
+ * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@Slf4j +public class MySqlLowCodeStorage implements LowCodeStorage { + + private final ResourceSchema schema; + private final SqlSessionFactory sqlSessionFactory; + private final Configuration configuration; + private final DatabaseDialect dialect; + private final String namespace; + private final AtomicLong statementIdCounter = new AtomicLong(0); + private final XMLLanguageDriver languageDriver; + + /** + * 构造函数 + * + * @param schema 资源 schema 定义 + * @param sqlSessionFactory MyBatis SqlSessionFactory + */ + public MySqlLowCodeStorage(ResourceSchema schema, SqlSessionFactory sqlSessionFactory) { + this.schema = schema; + this.sqlSessionFactory = sqlSessionFactory; + this.configuration = sqlSessionFactory.getConfiguration(); + this.dialect = detectDialect(); + this.namespace = "lowcode." + schema.getResourceName(); + this.languageDriver = new XMLLanguageDriver(); + } + + /** + * 检测数据库方言 + *

+ * 通过 Connection.getMetaData().getDatabaseProductName() + * 自动识别数据库类型,用于方言适配。 + * + * @return 数据库方言 + */ + private DatabaseDialect detectDialect() { + try (SqlSession session = sqlSessionFactory.openSession()) { + Connection con = session.getConnection(); + String productName = con.getMetaData().getDatabaseProductName(); + if (productName == null) { + return DatabaseDialect.MYSQL; + } + String lowerName = productName.toLowerCase(); + if (lowerName.contains("h2")) { + return DatabaseDialect.H2; + } + if (lowerName.contains("oracle")) { + return DatabaseDialect.ORACLE; + } + if (lowerName.contains("postgresql")) { + return DatabaseDialect.POSTGRESQL; + } + if (lowerName.contains("sql server") || lowerName.contains("microsoft")) { + return DatabaseDialect.SQL_SERVER; + } + return DatabaseDialect.MYSQL; + } catch (Exception e) { + log.warn("Failed to detect database dialect, defaulting to MySQL: {}", e.getMessage()); + return DatabaseDialect.MYSQL; + } + } + + @Override + public void initialize() { + String tableName = schema.getTableName(); + try (SqlSession session = sqlSessionFactory.openSession()) { + Connection con = session.getConnection(); + try (Statement stmt = con.createStatement()) { + stmt.execute(buildCreateTableSql()); + log.info("LowCode table initialized: {}", tableName); + } catch (SQLException e) { + log.warn("Failed to initialize table {} (may already exist): {}", tableName, e.getMessage()); + } + } + } + + /** + * 构建建表 SQL 语句 + *

+ * 根据 schema 中的字段定义自动生成 DDL 语句, + * 自动处理主键、唯一约束、索引等,根据数据库方言适配语法。 + * + * @return 建表 SQL + */ + private String buildCreateTableSql() { + StringBuilder sql = new StringBuilder(); + sql.append("CREATE TABLE IF NOT EXISTS ").append(schema.getTableName()).append(" ("); + + List columnDefs = new ArrayList<>(); + List pkFields = new ArrayList<>(); + List indexFields = new ArrayList<>(); + List uniqueFields = new ArrayList<>(); + + for (FieldSchema field : schema.getFields().values()) { + StringBuilder colDef = new StringBuilder(); + colDef.append(field.getName()).append(" "); + colDef.append(mapFieldType(field)); + + if (field.isPrimaryKey()) { + pkFields.add(field.getName()); + if (field.isAutoIncrement()) { + colDef.append(" AUTO_INCREMENT"); + } + } + if (!field.isNullable()) { + colDef.append(" NOT NULL"); + } + if (field.getDefaultValue() != null) { + colDef.append(" DEFAULT '").append(field.getDefaultValue()).append("'"); + } + if (field.isUnique()) { + uniqueFields.add(field.getName()); + } + if (field.isIndex()) { + indexFields.add(field.getName()); + } + columnDefs.add(colDef.toString()); + } + + sql.append(String.join(", ", columnDefs)); + + if (!pkFields.isEmpty()) { + sql.append(", PRIMARY KEY (").append(String.join(", ", pkFields)).append(")"); + } + + if (dialect == DatabaseDialect.MYSQL) { + for (String uk : uniqueFields) { + sql.append(", UNIQUE KEY uk_").append(uk).append(" (").append(uk).append(")"); + } + for (String idx : indexFields) { + sql.append(", KEY idx_").append(idx).append(" (").append(idx).append(")"); + } + sql.append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + } else { + for (String uk : uniqueFields) { + sql.append(", CONSTRAINT uk_").append(uk).append(" UNIQUE (").append(uk).append(")"); + } + for (String idx : indexFields) { + sql.append("); "); + sql.append("CREATE INDEX IF NOT EXISTS idx_").append(idx) + .append(" ON ").append(schema.getTableName()).append(" (").append(idx).append(")"); + return sql.toString(); + } + sql.append(")"); + } + + return sql.toString(); + } + + /** + * 将字段类型映射为 SQL 类型(按数据库方言适配) + * + * @param field 字段定义 + * @return SQL 类型字符串 + */ + private String mapFieldType(FieldSchema field) { + FieldType type = field.getType(); + return switch (type) { + case STRING -> "VARCHAR(" + field.getLength() + ")"; + case LONG -> "BIGINT"; + case INTEGER -> "INT"; + case BOOLEAN -> dialect == DatabaseDialect.MYSQL ? "TINYINT(1)" : "BOOLEAN"; + case DECIMAL -> "DECIMAL(" + field.getPrecision() + "," + field.getScale() + ")"; + case DATETIME -> dialect == DatabaseDialect.MYSQL ? "DATETIME" : "TIMESTAMP"; + case DATE -> "DATE"; + case TEXT -> "TEXT"; + case JSON -> dialect == DatabaseDialect.MYSQL ? "JSON" : "TEXT"; + default -> "VARCHAR(255)"; + }; + } + + /** + * 构建查询列名列表(替代 SELECT *) + *

+ * 根据 schema 中的字段定义生成明确的列名列表, + * 避免查询不必要的列,提升性能并减少数据传输。 + * + * @return 列名列表字符串 + */ + private String buildSelectColumns() { + return schema.getFields().values().stream() + .map(FieldSchema::getName) + .collect(Collectors.joining(", ")); + } + + /** + * 规范化查询结果的 Map 的 key 为小写 + *

+ * H2、Oracle 等数据库返回的列名可能是大写的, + * 需要统一转换为小写以便与 schema 中的字段名匹配。 + * + * @param row 原始查询结果行 + * @return 规范化后的 Map + */ + private Map normalizeRow(Map row) { + if (row == null) { + return null; + } + Map normalized = new LinkedHashMap<>(); + for (Map.Entry entry : row.entrySet()) { + normalized.put(entry.getKey().toLowerCase(), entry.getValue()); + } + return normalized; + } + + /** + * 规范化查询结果列表 + * + * @param rows 原始查询结果列表 + * @return 规范化后的列表 + */ + private List> normalizeRows(List> rows) { + if (rows == null || rows.isEmpty()) { + return rows != null ? rows : Collections.emptyList(); + } + return rows.stream() + .map(this::normalizeRow) + .collect(Collectors.toList()); + } + + /** + * 生成唯一的 MappedStatement ID + * + * @param prefix 前缀 + * @return 唯一的 statement ID + */ + private String nextStatementId(String prefix) { + return namespace + "." + prefix + "_" + statementIdCounter.incrementAndGet(); + } + + /** + * 动态注册 MappedStatement 并执行 SELECT 查询 + *

+ * 通过动态注册 MappedStatement 的方式,使 SQL 能够经过 MyBatis 拦截器链, + * 支持分页插件、数据权限、SQL 监控等所有 MyBatis 插件。 + * + * @param sql SQL 语句(使用 #{paramName} 格式的命名参数) + * @param params 参数 Map + * @return 查询结果列表 + */ + @SuppressWarnings("unchecked") + private List> executeSelect(String sql, Map params) { + String statementId = nextStatementId("select"); + try { + registerSelectStatement(statementId, sql); + try (SqlSession session = sqlSessionFactory.openSession(true)) { + List> results = session.selectList(statementId, params); + return normalizeRows(results); + } + } finally { + configuration.getMappedStatements().remove(statementId); + } + } + + /** + * 动态注册 MappedStatement 并执行 INSERT/UPDATE/DELETE + *

+ * 通过动态注册 MappedStatement 的方式,使 SQL 能够经过 MyBatis 拦截器链。 + * + * @param sql SQL 语句(使用 #{paramName} 格式的命名参数) + * @param params 参数 Map + * @param type SQL 命令类型 + * @return 影响的行数 + */ + private int executeUpdate(String sql, Map params, SqlCommandType type) { + String statementId = nextStatementId(type.name().toLowerCase()); + try { + registerUpdateStatement(statementId, sql, type); + try (SqlSession session = sqlSessionFactory.openSession(true)) { + return session.update(statementId, params); + } + } finally { + configuration.getMappedStatements().remove(statementId); + } + } + + /** + * 注册 SELECT 类型的 MappedStatement + *

+ * 配置 ResultMap 为 Map 类型,使查询结果以 Map 形式返回。 + * + * @param statementId 语句 ID + * @param sql SQL 语句 + */ + private void registerSelectStatement(String statementId, String sql) { + if (configuration.hasStatement(statementId)) { + return; + } + + org.apache.ibatis.mapping.SqlSource sqlSource = languageDriver.createSqlSource( + configuration, "", Map.class); + + ResultMap resultMap = new ResultMap.Builder( + configuration, + statementId + "-Inline", + Map.class, + new ArrayList(), + true + ).build(); + configuration.addResultMap(resultMap); + + MappedStatement.Builder builder = new MappedStatement.Builder( + configuration, statementId, sqlSource, SqlCommandType.SELECT); + builder.resultMaps(Collections.singletonList(resultMap)); + builder.resource(namespace + ".dynamic"); + + configuration.addMappedStatement(builder.build()); + } + + /** + * 注册 COUNT 查询类型的 MappedStatement(返回 Long 类型) + * + * @param statementId 语句 ID + * @param sql SQL 语句 + */ + private void registerCountStatement(String statementId, String sql) { + if (configuration.hasStatement(statementId)) { + return; + } + + org.apache.ibatis.mapping.SqlSource sqlSource = languageDriver.createSqlSource( + configuration, "", Map.class); + + ResultMap resultMap = new ResultMap.Builder( + configuration, + statementId + "-Inline", + Long.class, + new ArrayList(), + true + ).build(); + configuration.addResultMap(resultMap); + + MappedStatement.Builder builder = new MappedStatement.Builder( + configuration, statementId, sqlSource, SqlCommandType.SELECT); + builder.resultMaps(Collections.singletonList(resultMap)); + builder.resource(namespace + ".dynamic"); + + configuration.addMappedStatement(builder.build()); + } + + /** + * 注册 INSERT/UPDATE/DELETE 类型的 MappedStatement + * + * @param statementId 语句 ID + * @param sql SQL 语句 + * @param type SQL 命令类型 + */ + private void registerUpdateStatement(String statementId, String sql, SqlCommandType type) { + if (configuration.hasStatement(statementId)) { + return; + } + + org.apache.ibatis.mapping.SqlSource sqlSource = languageDriver.createSqlSource( + configuration, "", Map.class); + + MappedStatement.Builder builder = new MappedStatement.Builder( + configuration, statementId, sqlSource, type); + builder.resource(namespace + ".dynamic"); + + configuration.addMappedStatement(builder.build()); + } + + /** + * 执行带自增主键的 INSERT(获取生成的主键) + *

+ * 由于需要获取自增主键,使用 JDBC 原生方式执行。 + * 注意:此方法的 SQL 不经过 MyBatis 拦截器链。 + * + * @param sql SQL 语句(使用 ? 占位符) + * @param params 参数列表(按顺序) + * @return 生成的主键 + */ + private Object executeInsertWithGeneratedKey(String sql, List params) { + try (SqlSession session = sqlSessionFactory.openSession()) { + Connection con = session.getConnection(); + try (PreparedStatement ps = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + int idx = 1; + for (Object val : params) { + ps.setObject(idx++, val); + } + ps.executeUpdate(); + try (ResultSet rs = ps.getGeneratedKeys()) { + if (rs.next()) { + return rs.getObject(1); + } + } + } + } catch (SQLException e) { + log.error("Failed to execute insert with generated key: {}", e.getMessage()); + throw new RuntimeException("Insert failed", e); + } + return null; + } + + @Override + public Map save(Map data) { + Map rowData = new LinkedHashMap<>(data); + fillAutoFields(rowData, AutoFillType.CREATE); + fillAutoFields(rowData, AutoFillType.CREATE_UPDATE); + + String idField = schema.getIdFieldName(); + boolean hasId = rowData.containsKey(idField) && rowData.get(idField) != null; + + if (hasId) { + Map existing = findById(rowData.get(idField)); + if (existing != null) { + return doUpdate(rowData); + } + } + return doInsert(rowData); + } + + /** + * 执行插入操作 + *

+ * 自增主键场景:使用 JDBC 原生方式获取生成的主键(不经过拦截器) + * 非自增主键:使用 MyBatis SqlSession 执行(经过拦截器) + * + * @param data 数据 + * @return 插入后的数据(包含自动生成的主键) + */ + private Map doInsert(Map data) { + StringBuilder columns = new StringBuilder(); + StringBuilder jdbcPlaceholders = new StringBuilder(); + StringBuilder mybatisPlaceholders = new StringBuilder(); + Map namedParams = new HashMap<>(); + List jdbcParamList = new ArrayList<>(); + + boolean first = true; + for (Map.Entry entry : data.entrySet()) { + String fieldName = entry.getKey(); + if (schema.getField(fieldName) == null) { + continue; + } + FieldSchema field = schema.getField(fieldName); + if (field.isAutoIncrement() && entry.getValue() == null) { + continue; + } + if (!first) { + columns.append(", "); + jdbcPlaceholders.append(", "); + mybatisPlaceholders.append(", "); + } + columns.append(fieldName); + jdbcPlaceholders.append("?"); + mybatisPlaceholders.append("#{").append(fieldName).append("}"); + namedParams.put(fieldName, entry.getValue()); + jdbcParamList.add(entry.getValue()); + first = false; + } + + String mybatisSql = "INSERT INTO " + schema.getTableName() + " (" + columns + ") VALUES (" + mybatisPlaceholders + ")"; + String jdbcSql = "INSERT INTO " + schema.getTableName() + " (" + columns + ") VALUES (" + jdbcPlaceholders + ")"; + + FieldSchema idField = schema.getIdField(); + if (idField != null && idField.isAutoIncrement()) { + Object key = executeInsertWithGeneratedKey(jdbcSql, jdbcParamList); + if (key != null) { + data.put(idField.getName(), key); + } + } else { + executeUpdate(mybatisSql, namedParams, SqlCommandType.INSERT); + } + + return findById(data.get(schema.getIdFieldName())); + } + + /** + * 执行更新操作 + *

+ * 根据主键更新记录,自动填充更新时间字段。 + * 使用 MyBatis SqlSession 执行,SQL 经过拦截器链。 + * + * @param data 数据(必须包含主键) + * @return 更新后的数据 + */ + private Map doUpdate(Map data) { + StringBuilder setClause = new StringBuilder(); + Map params = new HashMap<>(); + String idFieldName = schema.getIdFieldName(); + + fillAutoFields(data, AutoFillType.UPDATE); + fillAutoFields(data, AutoFillType.CREATE_UPDATE); + + boolean first = true; + for (Map.Entry entry : data.entrySet()) { + String fieldName = entry.getKey(); + if (fieldName.equals(idFieldName)) { + params.put(fieldName, entry.getValue()); + continue; + } + if (schema.getField(fieldName) == null) { + continue; + } + if (!first) { + setClause.append(", "); + } + setClause.append(fieldName).append(" = #{").append(fieldName).append("}"); + params.put(fieldName, entry.getValue()); + first = false; + } + + String sql = "UPDATE " + schema.getTableName() + " SET " + setClause + + " WHERE " + idFieldName + " = #{" + idFieldName + "}"; + executeUpdate(sql, params, SqlCommandType.UPDATE); + + return findById(data.get(idFieldName)); + } + + @Override + public void removeById(Object id) { + String idFieldName = schema.getIdFieldName(); + String sql = "DELETE FROM " + schema.getTableName() + " WHERE " + idFieldName + " = #{id}"; + Map params = new HashMap<>(); + params.put("id", id); + executeUpdate(sql, params, SqlCommandType.DELETE); + } + + @Override + public Map findById(Object id) { + String idFieldName = schema.getIdFieldName(); + String sql = "SELECT " + buildSelectColumns() + " FROM " + schema.getTableName() + + " WHERE " + idFieldName + " = #{id}"; + Map params = new HashMap<>(); + params.put("id", id); + List> results = executeSelect(sql, params); + return results.isEmpty() ? null : results.get(0); + } + + @Override + public Map queryById(Object id) { + return findById(id); + } + + @Override + public Map queryOne(Map queryParams) { + List> list = queryList(queryParams); + return list.isEmpty() ? null : list.get(0); + } + + @Override + public Optional> queryOneOptional(Map queryParams) { + return Optional.ofNullable(queryOne(queryParams)); + } + + @Override + public List> queryList(Map queryParams) { + StringBuilder sql = new StringBuilder(); + sql.append("SELECT ").append(buildSelectColumns()).append(" FROM ").append(schema.getTableName()); + Map params = new HashMap<>(); + + if (queryParams != null && !queryParams.isEmpty()) { + StringBuilder where = new StringBuilder(" WHERE "); + boolean first = true; + for (Map.Entry entry : queryParams.entrySet()) { + String fieldName = entry.getKey(); + if (schema.getField(fieldName) == null) { + continue; + } + if (!first) { + where.append(" AND "); + } + where.append(fieldName).append(" = #{").append(fieldName).append("}"); + params.put(fieldName, entry.getValue()); + first = false; + } + if (!first) { + sql.append(where); + } + } + + return executeSelect(sql.toString(), params); + } + + @Override + public ResPage> queryPage(ReqPage reqPage) { + long pageNum = reqPage.getPage() != null ? reqPage.getPage() : 1; + long pageSize = reqPage.getSize() != null ? reqPage.getSize() : 10; + + long total = count(null); + ResPage> page = new ResPage<>(); + page.setCurrent(pageNum); + page.setSize(pageSize); + page.setTotal(total); + + if (total == 0) { + page.setRecords(Collections.emptyList()); + page.setPages(0L); + return page; + } + + long pages = total / pageSize + (total % pageSize == 0 ? 0 : 1); + page.setPages(pages); + + String baseSql = "SELECT " + buildSelectColumns() + " FROM " + schema.getTableName(); + String paginationSql = buildPaginationSql(baseSql, pageNum, pageSize); + + List> records = executeSelect(paginationSql, new HashMap<>()); + page.setRecords(records); + + return page; + } + + /** + * 构建分页 SQL(根据数据库方言适配) + *

+ * 支持的分页语法: + *

    + *
  • MySQL / H2:LIMIT ... OFFSET ...
  • + *
  • Oracle:ROWNUM 嵌套子查询
  • + *
  • PostgreSQL / SQL Server:OFFSET ... FETCH NEXT ... ONLY(SQL:2008 标准)
  • + *
+ * + * @param baseSql 基础查询 SQL + * @param pageNum 页码(从1开始) + * @param pageSize 每页大小 + * @return 分页 SQL + */ + private String buildPaginationSql(String baseSql, long pageNum, long pageSize) { + long offset = (pageNum - 1) * pageSize; + switch (dialect) { + case MYSQL, H2 -> { + return baseSql + " LIMIT " + pageSize + " OFFSET " + offset; + } + case ORACLE -> { + return "SELECT * FROM (SELECT ROWNUM rn, t.* FROM (" + baseSql + ") t WHERE ROWNUM <= " + + (offset + pageSize) + ") WHERE rn > " + offset; + } + default -> { + return baseSql + " OFFSET " + offset + " ROWS FETCH NEXT " + pageSize + " ROWS ONLY"; + } + } + } + + @Override + public List> saveBatch(List> dataList) { + if (dataList == null || dataList.isEmpty()) { + return Collections.emptyList(); + } + List> result = new ArrayList<>(); + for (Map data : dataList) { + result.add(save(data)); + } + return result; + } + + @Override + public void removeBatchByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return; + } + String idFieldName = schema.getIdFieldName(); + StringBuilder placeholders = new StringBuilder(); + Map params = new HashMap<>(); + for (int i = 0; i < ids.size(); i++) { + if (i > 0) { + placeholders.append(", "); + } + String paramName = "id_" + i; + placeholders.append("#{").append(paramName).append("}"); + params.put(paramName, ids.get(i)); + } + String sql = "DELETE FROM " + schema.getTableName() + " WHERE " + idFieldName + " IN (" + placeholders + ")"; + executeUpdate(sql, params, SqlCommandType.DELETE); + } + + @Override + public List> listByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return Collections.emptyList(); + } + String idFieldName = schema.getIdFieldName(); + StringBuilder placeholders = new StringBuilder(); + Map params = new HashMap<>(); + for (int i = 0; i < ids.size(); i++) { + if (i > 0) { + placeholders.append(", "); + } + String paramName = "id_" + i; + placeholders.append("#{").append(paramName).append("}"); + params.put(paramName, ids.get(i)); + } + String sql = "SELECT " + buildSelectColumns() + " FROM " + schema.getTableName() + + " WHERE " + idFieldName + " IN (" + placeholders + ")"; + return executeSelect(sql, params); + } + + @Override + public long count(Map queryParams) { + StringBuilder sql = new StringBuilder(); + sql.append("SELECT COUNT(*) FROM ").append(schema.getTableName()); + Map params = new HashMap<>(); + + if (queryParams != null && !queryParams.isEmpty()) { + StringBuilder where = new StringBuilder(" WHERE "); + boolean first = true; + for (Map.Entry entry : queryParams.entrySet()) { + String fieldName = entry.getKey(); + if (schema.getField(fieldName) == null) { + continue; + } + if (!first) { + where.append(" AND "); + } + where.append(fieldName).append(" = #{").append(fieldName).append("}"); + params.put(fieldName, entry.getValue()); + first = false; + } + if (!first) { + sql.append(where); + } + } + + String statementId = nextStatementId("count"); + try { + registerCountStatement(statementId, sql.toString()); + try (SqlSession session = sqlSessionFactory.openSession(true)) { + Object result = session.selectOne(statementId, params); + if (result instanceof Number) { + return ((Number) result).longValue(); + } + return 0L; + } + } finally { + configuration.getMappedStatements().remove(statementId); + } + } + + @Override + public boolean exists(Map queryParams) { + return count(queryParams) > 0; + } + + /** + * 填充自动字段 + *

+ * 根据字段的 autoFill 策略,自动填充创建时间、更新时间等字段。 + * 使用 putIfAbsent 确保用户显式设置的值不会被覆盖。 + * + * @param data 数据 + * @param fillType 填充类型 + */ + private void fillAutoFields(Map data, AutoFillType fillType) { + LocalDateTime now = LocalDateTime.now(); + for (FieldSchema field : schema.getFields().values()) { + if (field.getAutoFill() == fillType) { + String name = field.getName(); + switch (field.getType()) { + case DATETIME -> data.putIfAbsent(name, now); + case DATE -> data.putIfAbsent(name, now.toLocalDate()); + default -> { + } + } + } + } + } + + /** + * 数据库方言枚举 + *

+ * 用于适配不同数据库的 SQL 语法差异, + * 如建表语句、分页语句、数据类型等。 + */ + private enum DatabaseDialect { + /** MySQL */ + MYSQL, + /** H2 内存数据库 */ + H2, + /** Oracle */ + ORACLE, + /** SQL Server */ + SQL_SERVER, + /** PostgreSQL */ + POSTGRESQL + } +} \ No newline at end of file diff --git a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/configuration/MybatisPlusLowCodeAutoConfiguration.java b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/configuration/MybatisPlusLowCodeAutoConfiguration.java new file mode 100644 index 0000000..a39ee82 --- /dev/null +++ b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/lowcode/configuration/MybatisPlusLowCodeAutoConfiguration.java @@ -0,0 +1,41 @@ +package cn.structure.infra.mybatis.plus.lowcode.configuration; + +import cn.structure.infra.mybatis.plus.lowcode.MySqlLowCodeRepoFactory; +import org.apache.ibatis.session.SqlSessionFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +/** + * MyBatis Plus 低代码自动配置类 + *

+ * 当低代码功能启用时,自动注册 MySQL 低代码仓储工厂, + * 使低代码路由引擎能够创建 MySQL 类型的存储实例。 + *

+ * 基于 MyBatis SqlSession 实现,SQL 拦截器支持说明: + *

    + *
  • 所有 CRUD SQL 通过动态注册 MappedStatement 的方式执行
  • + *
  • SQL 会经过 MyBatis 拦截器链,支持分页插件、数据权限、SQL 监控等
  • + *
  • 明确列名查询:所有 SELECT 语句使用明确列名替代 SELECT *
  • + *
  • 多方言适配:支持 MySQL/H2/Oracle/PostgreSQL/SQL Server 自动适配
  • + *
+ * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@AutoConfiguration +@ConditionalOnProperty(prefix = "structure.infra.lowcode", name = "enabled", havingValue = "true", matchIfMissing = true) +public class MybatisPlusLowCodeAutoConfiguration { + + /** + * 注册 MySQL 低代码仓储工厂 + * + * @param sqlSessionFactory MyBatis SqlSessionFactory + * @return MySQL 低代码仓储工厂实例 + */ + @Bean + public MySqlLowCodeRepoFactory mySqlLowCodeRepoFactory(SqlSessionFactory sqlSessionFactory) { + return new MySqlLowCodeRepoFactory(sqlSessionFactory); + } +} \ No newline at end of file diff --git a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusDelegateBeanPostProcessor.java b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusDelegateBeanPostProcessor.java new file mode 100644 index 0000000..f8d1cb3 --- /dev/null +++ b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusDelegateBeanPostProcessor.java @@ -0,0 +1,67 @@ +package cn.structure.infra.mybatis.plus.repository; + +import cn.structure.infra.annotations.DelegateFor; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; + +@Slf4j +public class MybatisPlusDelegateBeanPostProcessor implements BeanPostProcessor, ApplicationContextAware { + + private ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof MybatisPlusRepositoryDelegate) { + MybatisPlusRepositoryDelegate delegate = (MybatisPlusRepositoryDelegate) bean; + DelegateFor annotation = bean.getClass().getAnnotation(DelegateFor.class); + if (annotation != null && annotation.po() != void.class) { + try { + Object mapper = findMapperByPoClass(annotation.po()); + if (mapper != null) { + delegate.setBaseMapper((BaseMapper) mapper); + delegate.setEntityClass(annotation.po()); + log.info("Injected BaseMapper into MybatisPlusRepositoryDelegate: {}", beanName); + } else { + log.warn("No BaseMapper found for PO class {} in MybatisPlusRepositoryDelegate {}", annotation.po().getSimpleName(), beanName); + } + } catch (Exception e) { + log.warn("Failed to inject BaseMapper into MybatisPlusRepositoryDelegate {}: {}", beanName, e.getMessage()); + } + } + } + return bean; + } + + private Object findMapperByPoClass(Class poClass) { + String poClassName = poClass.getName(); + String mapperClassName = poClassName.replace(".po.", ".mapper.") + .replace("PO", "Mapper"); + try { + Class mapperClass = Class.forName(mapperClassName); + return applicationContext.getBean(mapperClass); + } catch (ClassNotFoundException e) { + log.debug("Mapper class not found: {}", mapperClassName); + } catch (Exception e) { + log.debug("Failed to get mapper bean: {}", e.getMessage()); + } + + String simpleMapperName = poClass.getSimpleName().replace("PO", "Mapper"); + try { + return applicationContext.getBean(simpleMapperName); + } catch (Exception e) { + log.debug("Failed to get mapper by name: {}", simpleMapperName); + } + + return null; + } +} \ No newline at end of file diff --git a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusDelegateFactory.java b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusDelegateFactory.java new file mode 100644 index 0000000..1e2d22f --- /dev/null +++ b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusDelegateFactory.java @@ -0,0 +1,62 @@ +package cn.structure.infra.mybatis.plus.repository; + +import cn.structure.infra.repository.RepositoryDelegate; +import cn.structure.infra.repository.RepositoryDelegateFactory; +import cn.structure.infra.repository.RepositoryType; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.springframework.context.ApplicationContext; + +/** + * MyBatis Plus 仓储委托工厂 + *

+ * 自动创建 MybatisPlusRepositoryDelegate 实例 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +public class MybatisPlusDelegateFactory implements RepositoryDelegateFactory { + + private final ApplicationContext applicationContext; + + public MybatisPlusDelegateFactory(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Override + public RepositoryType getType() { + return RepositoryType.MYBATIS_PLUS; + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public RepositoryDelegate createDelegate(Class poClass, Class idClass) { + try { + BaseMapper mapper = (BaseMapper) findMapperByPoClass(poClass); + if (mapper == null) { + return null; + } + return new MybatisPlusRepositoryDelegate(mapper, poClass); + } catch (Exception e) { + return null; + } + } + + private Object findMapperByPoClass(Class poClass) { + String poClassName = poClass.getName(); + String mapperClassName = poClassName.replace(".po.", ".mapper.") + .replace("PO", "Mapper"); + try { + Class mapperClass = Class.forName(mapperClassName); + return applicationContext.getBean(mapperClass); + } catch (Exception e) { + String simpleMapperName = poClass.getSimpleName().replace("PO", "Mapper"); + for (String beanName : applicationContext.getBeanDefinitionNames()) { + if (beanName.endsWith(simpleMapperName)) { + return applicationContext.getBean(beanName); + } + } + return null; + } + } +} diff --git a/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusRepositoryDelegate.java b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusRepositoryDelegate.java new file mode 100644 index 0000000..ce5b5a6 --- /dev/null +++ b/structure-infra-mybatis-plus-starter/src/main/java/cn/structure/infra/mybatis/plus/repository/MybatisPlusRepositoryDelegate.java @@ -0,0 +1,249 @@ +package cn.structure.infra.mybatis.plus.repository; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.repository.RepositoryDelegate; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.extern.slf4j.Slf4j; + +import java.io.Serializable; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +@Slf4j +public class MybatisPlusRepositoryDelegate implements RepositoryDelegate { + + protected BaseMapper baseMapper; + protected Class entityClass; + protected String idFieldName; + + public MybatisPlusRepositoryDelegate() { + } + + public MybatisPlusRepositoryDelegate(BaseMapper baseMapper, Class entityClass) { + this(baseMapper, entityClass, "id"); + } + + public MybatisPlusRepositoryDelegate(BaseMapper baseMapper, Class entityClass, String idFieldName) { + this.baseMapper = baseMapper; + this.entityClass = entityClass; + this.idFieldName = idFieldName; + log.info("MybatisPlusRepositoryDelegate initialized for entity: {}", entityClass.getSimpleName()); + } + + public void setBaseMapper(BaseMapper baseMapper) { + this.baseMapper = baseMapper; + } + + public void setEntityClass(Class entityClass) { + this.entityClass = entityClass; + } + + public void setIdFieldName(String idFieldName) { + this.idFieldName = idFieldName; + } + + @Override + public T save(T entity) { + if (entity == null) { + return null; + } + ID id = getIdValue(entity); + if (id == null) { + baseMapper.insert(entity); + } else { + baseMapper.updateById(entity); + } + log.debug("Saved entity: id={}, entity={}", id, entity); + return entity; + } + + @Override + public void removeById(ID id) { + if (id != null) { + baseMapper.deleteById((Serializable) id); + log.debug("Removed entity: id={}", id); + } + } + + @Override + public T findById(ID id) { + if (id == null) { + return null; + } + T entity = baseMapper.selectById((Serializable) id); + log.debug("Find by id: id={}, found={}", id, entity != null); + return entity; + } + + @Override + public T queryById(ID id) { + return findById(id); + } + + @Override + public Optional queryByIdOptional(ID id) { + return Optional.ofNullable(queryById(id)); + } + + @Override + public T queryOne(T condition) { + if (condition == null) { + return null; + } + QueryWrapper queryWrapper = buildQueryWrapper(condition); + List results = baseMapper.selectList(queryWrapper); + return results.isEmpty() ? null : results.get(0); + } + + @Override + public Optional queryOneOptional(T condition) { + return Optional.ofNullable(queryOne(condition)); + } + + @Override + public List queryList(T condition) { + if (condition == null) { + return baseMapper.selectList(null); + } + QueryWrapper queryWrapper = buildQueryWrapper(condition); + return baseMapper.selectList(queryWrapper); + } + + @Override + public ResPage queryPage(ReqPage reqPage) { + long pageNum = reqPage.getPage() != null ? reqPage.getPage() : 1; + long pageSize = reqPage.getSize() != null ? reqPage.getSize() : 10; + + Page page = new Page<>(pageNum, pageSize); + IPage result = baseMapper.selectPage(page, null); + + ResPage resPage = new ResPage<>(); + resPage.setCurrent(result.getCurrent()); + resPage.setPages(result.getPages()); + resPage.setSize(result.getSize()); + resPage.setTotal(result.getTotal()); + resPage.setRecords(result.getRecords()); + + log.debug("Query page: page={}, size={}, total={}, records={}", + pageNum, pageSize, result.getTotal(), result.getRecords().size()); + return resPage; + } + + private QueryWrapper buildQueryWrapper(T condition) { + QueryWrapper queryWrapper = new QueryWrapper<>(); + try { + Field[] fields = getAllFields(condition.getClass()); + for (Field field : fields) { + field.setAccessible(true); + Object value = field.get(condition); + if (value != null) { + queryWrapper.eq(camelToUnderline(field.getName()), value); + } + } + } catch (Exception e) { + log.warn("Error building query wrapper: {}", e.getMessage()); + } + return queryWrapper; + } + + private Field[] getAllFields(Class clazz) { + List fields = new java.util.ArrayList<>(); + while (clazz != null && clazz != Object.class) { + fields.addAll(Arrays.asList(clazz.getDeclaredFields())); + clazz = clazz.getSuperclass(); + } + return fields.toArray(new Field[0]); + } + + @SuppressWarnings("unchecked") + private ID getIdValue(T entity) { + try { + Field field = findIdField(entity.getClass()); + if (field != null) { + field.setAccessible(true); + return (ID) field.get(entity); + } + } catch (Exception e) { + log.warn("Error getting id value: {}", e.getMessage()); + } + return null; + } + + private Field findIdField(Class clazz) { + try { + Field field = clazz.getDeclaredField(idFieldName); + return field; + } catch (NoSuchFieldException e) { + if (clazz.getSuperclass() != null && clazz.getSuperclass() != Object.class) { + return findIdField(clazz.getSuperclass()); + } + return null; + } + } + + private String camelToUnderline(String param) { + if (param == null || "".equals(param.trim())) { + return ""; + } + int len = param.length(); + StringBuilder sb = new StringBuilder(len); + for (int i = 0; i < len; i++) { + char c = param.charAt(i); + if (Character.isUpperCase(c)) { + sb.append("_"); + sb.append(Character.toLowerCase(c)); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + @Override + public List saveBatch(List entities) { + if (entities == null || entities.isEmpty()) { + return List.of(); + } + entities.forEach(baseMapper::insert); + return entities; + } + + @Override + public void removeBatchByIds(List ids) { + if (ids != null && !ids.isEmpty()) { + baseMapper.deleteBatchIds(ids.stream() + .map(id -> (Serializable) id) + .toList()); + } + } + + @Override + public List listByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + return baseMapper.selectBatchIds(ids.stream() + .map(id -> (Serializable) id) + .toList()); + } + + @Override + public long count(T condition) { + if (condition == null) { + return baseMapper.selectCount(null); + } + QueryWrapper queryWrapper = buildQueryWrapper(condition); + return baseMapper.selectCount(queryWrapper); + } + + @Override + public boolean exists(T condition) { + return count(condition) > 0; + } +} diff --git a/structure-infra-mybatis-plus-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/structure-infra-mybatis-plus-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..fbb9fbc --- /dev/null +++ b/structure-infra-mybatis-plus-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +cn.structure.infra.mybatis.plus.configuration.MybatisPlusAutoConfiguration +cn.structure.infra.mybatis.plus.lowcode.configuration.MybatisPlusLowCodeAutoConfiguration \ No newline at end of file diff --git a/structure-infra-sample/pom.xml b/structure-infra-sample/pom.xml new file mode 100644 index 0000000..a6d2157 --- /dev/null +++ b/structure-infra-sample/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + + cn.structured + structure-pro-infra + ${revision} + ../pom.xml + + + structure-infra-sample + ${revision} + pom + + + 21 + 21 + UTF-8 + + + + structure-infra-sample-core + structure-infra-sample-mybatis + structure-infra-sample-jpa + structure-infra-sample-mongodb + structure-infra-sample-elasticsearch + structure-infra-sample-cqrs + + + \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-core/pom.xml b/structure-infra-sample/structure-infra-sample-core/pom.xml new file mode 100644 index 0000000..0ae0475 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-core/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + + cn.structured + structure-infra-sample + ${revision} + ../pom.xml + + + structure-infra-sample-core + structure-infra-sample-core + 示例核心模块 - 共享 Entity、PO、Repository 接口 + jar + + + + + cn.structured + structure-infra-starter + ${revision} + + + + + cn.structured + structure-common + + + + + com.baomidou + mybatis-plus-spring-boot4-starter + true + + + + + jakarta.persistence + jakarta.persistence-api + true + + + + + org.springframework.data + spring-data-mongodb + true + + + + + org.springframework.data + spring-data-elasticsearch + true + + + + + org.projectlombok + lombok + provided + + + + diff --git a/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/domain/entity/UserEntity.java b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/domain/entity/UserEntity.java new file mode 100644 index 0000000..0a316c6 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/domain/entity/UserEntity.java @@ -0,0 +1,30 @@ +package cn.structure.infra.sample.domain.entity; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 用户实体 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@Data +public class UserEntity { + + private Long id; + + private String username; + + private String password; + + private String email; + + private Integer age; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/domain/repository/UserRepository.java b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/domain/repository/UserRepository.java new file mode 100644 index 0000000..99f17b3 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/domain/repository/UserRepository.java @@ -0,0 +1,18 @@ +package cn.structure.infra.sample.domain.repository; + +import cn.structure.common.repository.ICrudRepository; +import cn.structure.infra.sample.domain.entity.UserEntity; + +/** + *

+ * 用户仓储 + *

+ * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +public interface UserRepository extends ICrudRepository { + + UserEntity findByName(String name); +} diff --git a/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/config/CacheConfig.java b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/config/CacheConfig.java new file mode 100644 index 0000000..56d423a --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/config/CacheConfig.java @@ -0,0 +1,17 @@ +package cn.structure.infra.sample.infra.config; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Bean + public CacheManager cacheManager() { + return new ConcurrentMapCacheManager(); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/po/UserPO.java b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/po/UserPO.java new file mode 100644 index 0000000..8250b28 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/po/UserPO.java @@ -0,0 +1,36 @@ +package cn.structure.infra.sample.infra.po; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import jakarta.persistence.*; +import lombok.Data; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; + +@Data +@TableName("t_user") +@Entity +@Table(name = "t_user") +@Document(collection = "t_user") +@org.springframework.data.elasticsearch.annotations.Document(indexName = "t_user") +public class UserPO { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @TableId(type = IdType.AUTO) + private Long id; + + private String username; + + private String password; + + private String email; + + private Integer age; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/repository/AbstractUserRepositoryImpl.java b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/repository/AbstractUserRepositoryImpl.java new file mode 100644 index 0000000..20fef2c --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/repository/AbstractUserRepositoryImpl.java @@ -0,0 +1,25 @@ +package cn.structure.infra.sample.infra.repository; + +import cn.structure.infra.repository.RepositoryFacade; +import cn.structure.infra.sample.domain.entity.UserEntity; +import cn.structure.infra.sample.domain.repository.UserRepository; +import cn.structure.infra.sample.infra.po.UserPO; +import cn.structure.infra.sample.infra.repository.delegate.UserRepositoryDelegate; + +/** + * 用户仓储基类 + *

+ * 提供通用的仓储实现,各存储技术模块可以继承此类并指定具体的存储类型 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +public abstract class AbstractUserRepositoryImpl extends RepositoryFacade implements UserRepository { + + @Override + public UserEntity findByName(String name) { + UserPO po = this.baseDelegate.finByName(name); + return this.toEntity(po); + } +} diff --git a/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/repository/delegate/UserRepositoryDelegate.java b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/repository/delegate/UserRepositoryDelegate.java new file mode 100644 index 0000000..5fdb293 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-core/src/main/java/cn/structure/infra/sample/infra/repository/delegate/UserRepositoryDelegate.java @@ -0,0 +1,18 @@ +package cn.structure.infra.sample.infra.repository.delegate; + +import cn.structure.infra.repository.RepositoryDelegate; +import cn.structure.infra.sample.infra.po.UserPO; + +/** + *

+ * 用户仓储代理 + *

+ * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +public interface UserRepositoryDelegate extends RepositoryDelegate { + + UserPO finByName(String name); +} diff --git a/structure-infra-sample/structure-infra-sample-cqrs/pom.xml b/structure-infra-sample/structure-infra-sample-cqrs/pom.xml new file mode 100644 index 0000000..19c931f --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-cqrs/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + + cn.structured + structure-infra-sample + ${revision} + ../pom.xml + + + structure-infra-sample-cqrs + structure-infra-sample-cqrs + CQRS 读写分离示例 - 演示多代理模式 + jar + + + + + cn.structured + structure-infra-sample-core + ${revision} + + + + cn.structured + structure-infra-sample-mybatis + ${revision} + + + + cn.structured + structure-infra-starter + ${revision} + + + + cn.structured + structure-infra-mybatis-plus-starter + ${revision} + + + + cn.structured + structure-infra-elasticsearch-starter + ${revision} + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + com.h2database + h2 + test + + + + + org.mockito + mockito-core + test + + + + org.projectlombok + lombok + provided + + + \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-cqrs/src/main/java/cn/structure/infra/sample/cqrs/CqrsApplication.java b/structure-infra-sample/structure-infra-sample-cqrs/src/main/java/cn/structure/infra/sample/cqrs/CqrsApplication.java new file mode 100644 index 0000000..04e6ede --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-cqrs/src/main/java/cn/structure/infra/sample/cqrs/CqrsApplication.java @@ -0,0 +1,36 @@ +package cn.structure.infra.sample.cqrs; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * CQRS 示例启动类 + *

+ * 演示读写分离模式: + * - 写操作通过 MyBatis Plus 代理执行 + * - 读操作通过 Elasticsearch 代理执行 + *

+ * 同时加载多种目标代理: + * - BASE 代理(MyBatis Plus):负责写操作 + * - READ 代理(Elasticsearch):负责读操作 + *

+ * 当读代理执行失败时,自动回退到 BASE 代理执行读操作。 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@SpringBootApplication(scanBasePackages = { + "cn.structure.infra.sample", + "cn.structure.infra.repository", + "cn.structure.infra.mybatis.plus", + "cn.structure.infra.elasticsearch" +}) +@MapperScan("cn.structure.infra.sample.infra.mapper") +public class CqrsApplication { + + public static void main(String[] args) { + SpringApplication.run(CqrsApplication.class, args); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-cqrs/src/main/java/cn/structure/infra/sample/cqrs/infra/delegate/read/UserReadDelegate.java b/structure-infra-sample/structure-infra-sample-cqrs/src/main/java/cn/structure/infra/sample/cqrs/infra/delegate/read/UserReadDelegate.java new file mode 100644 index 0000000..9ed0aee --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-cqrs/src/main/java/cn/structure/infra/sample/cqrs/infra/delegate/read/UserReadDelegate.java @@ -0,0 +1,40 @@ +package cn.structure.infra.sample.cqrs.infra.delegate.read; + +import cn.structure.infra.annotations.DelegateFor; +import cn.structure.infra.elasticsearch.repository.ElasticsearchRepositoryDelegate; +import cn.structure.infra.repository.DelegateType; +import cn.structure.infra.repository.RepositoryType; +import cn.structure.infra.sample.infra.po.UserPO; +import cn.structure.infra.sample.infra.repository.delegate.UserRepositoryDelegate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 用户读代理(READ) + *

+ * 模拟 Elasticsearch 等读数据源,只实现读操作,不实现写操作。 + *

+ * 特点: + * - 只实现 IQueryDelegate 接口(只读能力) + * - 不实现 RepositoryDelegate 接口(无写操作) + * - 可以与写代理是完全不同的类型 + * - 如果读代理执行失败,自动回退到写代理(baseDelegate) + *

+ * delegateType = READ 表示这是读代理 + */ +@Slf4j +@Component +@DelegateFor( + name = "userCqrsRepository", + po = UserPO.class, + delegateType = DelegateType.READ, + type = RepositoryType.ELASTICSEARCH + +) +public class UserReadDelegate extends ElasticsearchRepositoryDelegate implements UserRepositoryDelegate { + + @Override + public UserPO finByName(String name) { + return null; + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-cqrs/src/main/java/cn/structure/infra/sample/cqrs/infra/delegate/write/UserWriteDelegate.java b/structure-infra-sample/structure-infra-sample-cqrs/src/main/java/cn/structure/infra/sample/cqrs/infra/delegate/write/UserWriteDelegate.java new file mode 100644 index 0000000..be26c11 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-cqrs/src/main/java/cn/structure/infra/sample/cqrs/infra/delegate/write/UserWriteDelegate.java @@ -0,0 +1,39 @@ +package cn.structure.infra.sample.cqrs.infra.delegate.write; + +import cn.structure.infra.annotations.DelegateFor; +import cn.structure.infra.mybatis.plus.repository.MybatisPlusRepositoryDelegate; +import cn.structure.infra.repository.DelegateType; +import cn.structure.infra.repository.RepositoryType; +import cn.structure.infra.sample.infra.po.UserPO; +import cn.structure.infra.sample.infra.repository.delegate.UserRepositoryDelegate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 用户写代理(BASE) + *

+ * 模拟数据库写操作,负责处理所有写操作: + * - save + * - removeById + * - saveBatch + * - removeBatchByIds + *

+ * 同时也可以处理读操作(作为读操作的兜底) + *

+ * delegateType = BASE 表示这是基础/写代理 + */ +@Slf4j +@Component +@DelegateFor( + name = "userCqrsRepository", + po = UserPO.class, + delegateType = DelegateType.BASE, + type = RepositoryType.MYBATIS_PLUS +) +public class UserWriteDelegate extends MybatisPlusRepositoryDelegate implements UserRepositoryDelegate { + + @Override + public UserPO finByName(String name) { + return null; + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-cqrs/src/main/java/cn/structure/infra/sample/cqrs/infra/repositoory/UserCqrsRepository.java b/structure-infra-sample/structure-infra-sample-cqrs/src/main/java/cn/structure/infra/sample/cqrs/infra/repositoory/UserCqrsRepository.java new file mode 100644 index 0000000..906080c --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-cqrs/src/main/java/cn/structure/infra/sample/cqrs/infra/repositoory/UserCqrsRepository.java @@ -0,0 +1,36 @@ +package cn.structure.infra.sample.cqrs.infra.repositoory; + +import cn.structure.infra.annotations.Repository; +import cn.structure.infra.repository.InMemoryRepositoryDelegate; +import cn.structure.infra.repository.RepositoryFacade; +import cn.structure.infra.sample.cqrs.infra.delegate.read.UserReadDelegate; +import cn.structure.infra.sample.cqrs.infra.delegate.write.UserWriteDelegate; +import cn.structure.infra.sample.domain.entity.UserEntity; +import cn.structure.infra.sample.domain.repository.UserRepository; +import cn.structure.infra.sample.infra.po.UserPO; +import org.springframework.stereotype.Component; + +/** + * 用户 CQRS 仓储 + *

+ * 启用了 CQRS 读写分离模式: + * - 写操作:通过 baseDelegate(写代理)执行 + * - 读操作:通过 readDelegate(读代理)执行,失败自动回退到 baseDelegate + *

+ * 使用条件(必须同时满足): + * 1. cqrs = true + * 2. readDelegateClass 指定了读代理类 + */ +@Repository( + entity = UserEntity.class, + po = UserPO.class, + cqrs = true, + readDelegateClass = UserReadDelegate.class +) +@Component("userCqrsRepository") +public class UserCqrsRepository extends RepositoryFacade implements UserRepository { + @Override + public UserEntity findByName(String name) { + return null; + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-cqrs/src/test/java/cn/structure/infra/sample/cqrs/UserCqrsRepositoryTest.java b/structure-infra-sample/structure-infra-sample-cqrs/src/test/java/cn/structure/infra/sample/cqrs/UserCqrsRepositoryTest.java new file mode 100644 index 0000000..8ea0fa6 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-cqrs/src/test/java/cn/structure/infra/sample/cqrs/UserCqrsRepositoryTest.java @@ -0,0 +1,281 @@ +package cn.structure.infra.sample.cqrs; + +import cn.structure.infra.sample.cqrs.config.CqrsTestConfig; +import cn.structure.infra.sample.cqrs.infra.delegate.write.UserWriteDelegate; +import cn.structure.infra.sample.cqrs.infra.repositoory.UserCqrsRepository; +import cn.structure.infra.repository.IQueryDelegate; +import cn.structure.infra.sample.infra.po.UserPO; +import cn.structure.infra.sample.domain.entity.UserEntity; +import cn.structure.infra.sample.domain.repository.UserRepository; +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * CQRS 仓储测试类 + *

+ * 测试读写分离模式下的仓储操作: + *

    + *
  • 写操作通过 BASE 代理(MyBatis Plus)执行
  • + *
  • 读操作通过 READ 代理(Elasticsearch)执行
  • + *
  • 当 READ 代理失败时,自动回退到 BASE 代理
  • + *
+ *

+ * 验证多种目标代理同时加载的工作机制。 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@Slf4j +@SpringBootTest(classes = CqrsTestConfig.class) +@DisplayName("CQRS 读写分离仓储测试") +class UserCqrsRepositoryTest { + + @Autowired + private UserCqrsRepository userCqrsRepository; + + @Autowired(required = false) + private UserRepository userRepository; + + /** + * 创建测试用户实体 + * + * @param username 用户名 + * @param email 邮箱 + * @param age 年龄 + * @return 用户实体 + */ + private UserEntity createUser(String username, String email, Integer age) { + UserEntity user = new UserEntity(); + user.setUsername(username); + user.setEmail(email); + user.setAge(age); + user.setPassword("123456"); + user.setCreateTime(LocalDateTime.now()); + user.setUpdateTime(LocalDateTime.now()); + return user; + } + + @BeforeEach + void setUp() { + log.info("========== CQRS 测试开始 =========="); + } + + @Test + @DisplayName("测试 CQRS 仓储注入") + void testCqrsRepositoryInjection() { + assertNotNull(userCqrsRepository, "CQRS 仓储应该被成功注入"); + log.info("✓ CQRS 仓储注入成功: {}", userCqrsRepository.getClass().getName()); + } + + @Test + @DisplayName("测试 BASE 写代理注入") + void testBaseDelegateInjection() { + assertNotNull(userCqrsRepository, "CQRS 仓储应该被注入"); + + // 验证 BASE 代理存在 + UserWriteDelegate baseDelegate = userCqrsRepository.getBaseDelegate(); + assertNotNull(baseDelegate, "BASE 写代理应该被注入"); + log.info("✓ BASE 写代理注入成功: {}", baseDelegate.getClass().getName()); + } + + @Test + @DisplayName("测试 READ 读代理注入") + void testReadDelegateInjection() { + assertNotNull(userCqrsRepository, "CQRS 仓储应该被注入"); + + // 验证 READ 代理存在 + IQueryDelegate readDelegate = userCqrsRepository.getReadDelegate(); + assertNotNull(readDelegate, "READ 读代理应该被注入"); + log.info("✓ READ 读代理注入成功: {}", readDelegate.getClass().getName()); + } + + @Test + @DisplayName("测试多种代理同时加载") + void testMultipleDelegatesLoaded() { + assertNotNull(userCqrsRepository, "CQRS 仓储应该被注入"); + + // 验证同时加载了两种代理 + UserWriteDelegate baseDelegate = userCqrsRepository.getBaseDelegate(); + IQueryDelegate readDelegate = userCqrsRepository.getReadDelegate(); + + assertNotNull(baseDelegate, "BASE 代理应该被加载"); + assertNotNull(readDelegate, "READ 代理应该被加载"); + + log.info("✓ 多种代理同时加载成功:"); + log.info(" - BASE 代理: {}", baseDelegate.getClass().getSimpleName()); + log.info(" - READ 代理: {}", readDelegate.getClass().getSimpleName()); + } + + @Test + @DisplayName("测试写操作使用 BASE 代理") + void testWriteOperationUsesBaseDelegate() { + // 执行写操作(save)- 应该使用 BASE 代理 + UserEntity user = createUser("writeUser", "write@test.com", 25); + UserEntity saved = userCqrsRepository.save(user); + + assertNotNull(saved, "写操作应该返回结果"); + assertNotNull(saved.getId(), "写操作应该生成 ID"); + + log.info("✓ 写操作成功(使用 BASE 代理):"); + log.info(" - 用户ID: {}", saved.getId()); + log.info(" - 用户名: {}", saved.getUsername()); + } + + @Test + @DisplayName("测试读操作使用 READ 代理") + void testReadOperationUsesReadDelegate() { + // 先写入数据(使用 BASE 代理) + UserEntity user = createUser("readUser", "read@test.com", 30); + UserEntity saved = userCqrsRepository.save(user); + + // 执行读操作(findById)- 应该使用 READ 代理 + UserEntity found = userCqrsRepository.findById(saved.getId()); + + // 注意:由于 Mock Elasticsearch 和真实数据库不同步, + // 这里主要验证读操作能够正常执行,不一定返回数据 + log.info("✓ 读操作执行完成(使用 READ 代理):"); + log.info(" - 查询ID: {}", saved.getId()); + log.info(" - 查询结果: {}", found != null ? "找到" : "未找到"); + } + + @Test + @DisplayName("测试删除操作使用 BASE 代理") + void testDeleteOperationUsesBaseDelegate() { + // 创建并保存用户 + UserEntity user = createUser("deleteUser", "delete@test.com", 28); + UserEntity saved = userCqrsRepository.save(user); + assertNotNull(saved.getId()); + + // 执行删除操作 - 应该使用 BASE 代理 + userCqrsRepository.removeById(saved.getId()); + + // 验证删除成功 + UserEntity found = userCqrsRepository.findById(saved.getId()); + // 由于 CQRS 模式下读代理和写代理可能不同步,这里只验证删除操作执行成功 + log.info("✓ 删除操作执行完成(使用 BASE 代理)"); + } + + @Test + @DisplayName("测试批量保存使用 BASE 代理") + void testBatchSaveUsesBaseDelegate() { + List users = List.of( + createUser("batch1", "batch1@test.com", 20), + createUser("batch2", "batch2@test.com", 25), + createUser("batch3", "batch3@test.com", 30) + ); + + List savedUsers = userCqrsRepository.saveBatch(users); + + assertNotNull(savedUsers, "批量保存应该返回结果"); + assertTrue(savedUsers.size() >= 3, "批量保存应该成功"); + + log.info("✓ 批量保存成功(使用 BASE 代理):"); + log.info(" - 保存数量: {}", savedUsers.size()); + } + + @Test + @DisplayName("测试 queryByIdOptional 读操作") + void testQueryByIdOptional() { + // 先保存数据 + UserEntity user = createUser("optionalUser", "optional@test.com", 22); + UserEntity saved = userCqrsRepository.save(user); + + // 执行 Optional 查询 - 使用 READ 代理 + Optional optional = userCqrsRepository.queryByIdOptional(saved.getId()); + + log.info("✓ Optional 查询完成(使用 READ 代理):"); + log.info(" - 查询ID: {}", saved.getId()); + log.info(" - 结果存在: {}", optional.isPresent()); + } + + @Test + @DisplayName("测试 queryList 读操作") + void testQueryList() { + // 执行列表查询 - 使用 READ 代理 + List list = userCqrsRepository.queryList(null); + + assertNotNull(list, "列表查询应该返回结果"); + + log.info("✓ 列表查询完成(使用 READ 代理):"); + log.info(" - 查询结果数量: {}", list.size()); + } + + @Test + @DisplayName("测试 queryPage 分页读操作") + void testQueryPage() { + ReqPage reqPage = new ReqPage(); + reqPage.setPage(1); + reqPage.setSize(10); + + // 执行分页查询 - 使用 READ 代理 + ResPage page = userCqrsRepository.queryPage(reqPage); + + assertNotNull(page, "分页查询应该返回结果"); + + log.info("✓ 分页查询完成(使用 READ 代理):"); + log.info(" - 当前页: {}", page.getCurrent()); + log.info(" - 每页数量: {}", page.getSize()); + log.info(" - 总数量: {}", page.getTotal()); + log.info(" - 记录数: {}", page.getRecords().size()); + } + + @Test + @DisplayName("测试 CQRS Entity <-> PO 转换") + void testEntityPoConversion() { + // 保存实体(BASE 代理处理) + UserEntity user = createUser("convertUser", "convert@test.com", 28); + UserEntity saved = userCqrsRepository.save(user); + + assertNotNull(saved.getId(), "保存后应该有 ID"); + + // 验证转换过程 + log.info("✓ Entity -> PO -> Entity 转换成功:"); + log.info(" - 原始实体: username={}, email={}, age={}", + user.getUsername(), user.getEmail(), user.getAge()); + log.info(" - 保存实体: id={}, username={}, email={}, age={}", + saved.getId(), saved.getUsername(), saved.getEmail(), saved.getAge()); + } + + @Test + @DisplayName("测试 CQRS 模式下的完整 CRUD 流程") + void testFullCqrsCrudFlow() { + log.info("========== CQRS 完整 CRUD 流程测试 =========="); + + // 1. 创建(写操作 - BASE 代理) + UserEntity user = createUser("cqrsFullFlowUniqueUser", "cqrsFullFlowUnique@test.com", 35); + UserEntity saved = userCqrsRepository.save(user); + log.info("Step 1 - CREATE(BASE 代理): id={}", saved.getId()); + assertNotNull(saved.getId()); + + // 2. 读取(读操作 - READ 代理) + UserEntity found = userCqrsRepository.findById(saved.getId()); + log.info("Step 2 - READ(READ 代理): result={}", found != null ? "找到" : "未找到"); + + // 3. 删除(写操作 - BASE 代理) + userCqrsRepository.removeById(saved.getId()); + log.info("Step 3 - DELETE(BASE 代理): 执行完成"); + + log.info("✓ CQRS 完整 CRUD 流程测试完成"); + } + + @Test + @DisplayName("测试 UserRepository 接口注入") + void testUserRepositoryInterfaceInjection() { + // 验证接口注入 + assertNotNull(userRepository, "UserRepository 接口应该被注入"); + log.info("✓ UserRepository 接口注入成功: {}", userRepository.getClass().getName()); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-cqrs/src/test/java/cn/structure/infra/sample/cqrs/config/CqrsTestConfig.java b/structure-infra-sample/structure-infra-sample-cqrs/src/test/java/cn/structure/infra/sample/cqrs/config/CqrsTestConfig.java new file mode 100644 index 0000000..9ec2754 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-cqrs/src/test/java/cn/structure/infra/sample/cqrs/config/CqrsTestConfig.java @@ -0,0 +1,87 @@ +package cn.structure.infra.sample.cqrs.config; + +import cn.structure.infra.elasticsearch.repository.ElasticsearchDelegateBeanPostProcessor; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * CQRS 测试配置 + *

+ * 同时加载多种目标代理,用于测试读写分离模式: + *

    + *
  • BASE 代理(MyBatis Plus):负责写操作
  • + *
  • READ 代理(Elasticsearch):负责读操作
  • + *
+ *

+ * 排除不使用的自动配置: + *

    + *
  • JPA 相关配置
  • + *
  • MongoDB 相关配置
  • + *
+ *

+ * 启用的配置: + *

    + *
  • MyBatis Plus(用于 BASE 写代理)
  • + *
  • Elasticsearch(用于 READ 读代理,通过 Mock 实现)
  • + *
+ * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@Configuration +@SpringBootApplication(excludeName = { + "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration", + "org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration", + "org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration", + "org.springframework.boot.autoconfigure.data.mongo.MongoRepositoriesAutoConfiguration" +}) +@ComponentScan(basePackages = { + "cn.structure.infra.sample.cqrs", + "cn.structure.infra.sample.infra", + "cn.structure.infra.repository", + "cn.structure.infra.mybatis.plus", + "cn.structure.infra.elasticsearch" +}) +@MapperScan("cn.structure.infra.sample.infra.mapper") +@Import({ + cn.structure.infra.mybatis.plus.configuration.MybatisPlusAutoConfiguration.class, + cn.structure.infra.elasticsearch.configuration.ElasticsearchAutoConfiguration.class, + MockElasticsearchConfiguration.class +}) +public class CqrsTestConfig { + + /** + * MyBatis Plus 分页插件 + *

+ * 用于支持 MyBatis Plus 的分页查询功能 + * + * @return MybatisPlusInterceptor 实例 + */ + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2)); + return interceptor; + } + + /** + * Elasticsearch 委托 Bean 后处理器 + *

+ * 显式注册,确保 Mock 环境下 ElasticsearchOperations 能正确注入到 Delegate 中。 + * 由于测试环境下 @ConditionalOnBean 可能因注册顺序问题不满足,因此手动注册。 + * + * @return ElasticsearchDelegateBeanPostProcessor 实例 + */ + @Bean + public ElasticsearchDelegateBeanPostProcessor elasticsearchDelegateBeanPostProcessor() { + return new ElasticsearchDelegateBeanPostProcessor(); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-cqrs/src/test/java/cn/structure/infra/sample/cqrs/config/MockElasticsearchConfiguration.java b/structure-infra-sample/structure-infra-sample-cqrs/src/test/java/cn/structure/infra/sample/cqrs/config/MockElasticsearchConfiguration.java new file mode 100644 index 0000000..8cea874 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-cqrs/src/test/java/cn/structure/infra/sample/cqrs/config/MockElasticsearchConfiguration.java @@ -0,0 +1,264 @@ +package cn.structure.infra.sample.cqrs.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.domain.Pageable; +import org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.data.elasticsearch.core.SearchHitsImpl; +import org.springframework.data.elasticsearch.core.TotalHitsRelation; +import org.springframework.data.elasticsearch.core.query.Query; + +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Mock Elasticsearch 配置 + *

+ * 用于测试环境中模拟 Elasticsearch 操作,避免依赖真实的 Elasticsearch 服务。 + *

+ * 模拟功能: + *

    + *
  • save:保存文档,自动生成 ID
  • + *
  • get:根据 ID 获取文档
  • + *
  • delete:根据 ID 删除文档
  • + *
  • search:搜索文档,支持分页
  • + *
  • count:统计文档数量
  • + *
+ *

+ * 数据存储在内存中的 Map 结构,支持按 PO 类型分类存储。 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@TestConfiguration +public class MockElasticsearchConfiguration { + + /** + * 内存数据存储,按 PO 类型分类 + */ + private final Map, Map> dataStore = new HashMap<>(); + + /** + * ID 生成器 + */ + private long idGenerator = 1; + + /** + * 重置数据存储 + *

+ * 用于测试方法间清理数据 + */ + public void reset() { + dataStore.clear(); + idGenerator = 1; + } + + /** + * 创建 Mock 的 ElasticsearchTemplate + *

+ * 模拟 Elasticsearch 的基本操作: + *

    + *
  • 保存文档(自动生成 ID)
  • + *
  • 根据 ID 获取文档
  • + *
  • 根据 ID 删除文档
  • + *
  • 搜索文档(支持分页)
  • + *
  • 统计文档数量
  • + *
+ * + * @return Mock 的 ElasticsearchTemplate 实例 + */ + @Bean + @Primary + public ElasticsearchTemplate elasticsearchTemplate() { + ElasticsearchTemplate template = mock(ElasticsearchTemplate.class); + + // 模拟 save 操作 + when(template.save(any(Object.class))).thenAnswer(invocation -> { + Object po = invocation.getArgument(0); + Class poClass = po.getClass(); + + Map classStore = dataStore.computeIfAbsent(poClass, k -> new HashMap<>()); + + try { + Field idField = findIdField(poClass); + if (idField != null) { + idField.setAccessible(true); + Object idValue = idField.get(po); + Long id = null; + + if (idValue == null || (idValue instanceof Number && ((Number) idValue).longValue() == 0)) { + id = idGenerator++; + setIdValue(po, id); + } else if (idValue instanceof Long) { + id = (Long) idValue; + } else if (idValue instanceof String) { + id = Long.valueOf((String) idValue); + } else if (idValue instanceof Number) { + id = ((Number) idValue).longValue(); + } + + if (id != null) { + classStore.put(id, po); + } + } + } catch (Exception ignored) { + } + + return po; + }); + + // 模拟 get 操作 + when(template.get(anyString(), any(Class.class))).thenAnswer(invocation -> { + String id = invocation.getArgument(0); + Class poClass = invocation.getArgument(1); + Map classStore = dataStore.get(poClass); + return classStore != null ? classStore.get(Long.valueOf(id)) : null; + }); + + // 模拟 delete 操作 + when(template.delete(anyString(), any(Class.class))).thenAnswer(invocation -> { + String id = invocation.getArgument(0); + Class poClass = invocation.getArgument(1); + Map classStore = dataStore.get(poClass); + if (classStore != null) { + classStore.remove(Long.valueOf(id)); + } + return id; + }); + + // 模拟 search 操作 + when(template.search(any(Query.class), any(Class.class))).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + Class poClass = invocation.getArgument(1); + + List allData = getAllData(poClass); + + Pageable pageable = query.getPageable(); + List pageData = new ArrayList<>(allData); + if (pageable != null && pageable.isPaged()) { + int pageNum = pageable.getPageNumber(); + int pageSize = pageable.getPageSize(); + int fromIndex = pageNum * pageSize; + int toIndex = Math.min(fromIndex + pageSize, allData.size()); + if (fromIndex >= allData.size()) { + pageData = new ArrayList<>(); + } else { + pageData = new ArrayList<>(allData.subList(fromIndex, toIndex)); + } + } + + List> searchHits = new ArrayList<>(); + for (Object po : pageData) { + SearchHit hit = mock(SearchHit.class); + try { + Field idField = findIdField(poClass); + if (idField != null) { + idField.setAccessible(true); + Object idValue = idField.get(po); + when(hit.getId()).thenReturn(String.valueOf(idValue)); + } + } catch (Exception ignored) { + } + when(hit.getContent()).thenReturn(po); + searchHits.add(hit); + } + + return new SearchHitsImpl<>( + allData.size(), + TotalHitsRelation.EQUAL_TO, + 0.0f, + Duration.ZERO, + null, + null, + searchHits, + null, + null, + null + ); + }); + + // 模拟 count 操作 + when(template.count(any(Query.class), any(Class.class))).thenAnswer(invocation -> { + Class poClass = invocation.getArgument(1); + Map classStore = dataStore.get(poClass); + return classStore != null ? (long) classStore.size() : 0L; + }); + + return template; + } + + /** + * 获取指定 PO 类的所有数据 + * + * @param poClass PO 类 + * @return 数据列表 + */ + private List getAllData(Class poClass) { + Map classStore = dataStore.get(poClass); + return classStore != null ? new ArrayList<>(classStore.values()) : new ArrayList<>(); + } + + /** + * 查找 ID 字段 + * + * @param clazz 类 + * @return ID 字段 + */ + private Field findIdField(Class clazz) { + return findField(clazz, "id"); + } + + /** + * 递归查找字段 + * + * @param clazz 类 + * @param fieldName 字段名 + * @return 字段 + */ + private Field findField(Class clazz, String fieldName) { + try { + return clazz.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + if (clazz.getSuperclass() != null && clazz.getSuperclass() != Object.class) { + return findField(clazz.getSuperclass(), fieldName); + } + return null; + } + } + + /** + * 设置 ID 值 + * + * @param po PO 对象 + * @param id ID 值 + * @throws Exception 异常 + */ + private void setIdValue(Object po, Long id) throws Exception { + Field field = findIdField(po.getClass()); + if (field != null) { + field.setAccessible(true); + if (field.getType() == Long.class || field.getType() == long.class) { + field.set(po, id); + } else if (field.getType() == Integer.class || field.getType() == int.class) { + field.set(po, id.intValue()); + } else if (field.getType() == String.class) { + field.set(po, String.valueOf(id)); + } else { + field.set(po, id); + } + } + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-cqrs/src/test/resources/application.yml b/structure-infra-sample/structure-infra-sample-cqrs/src/test/resources/application.yml new file mode 100644 index 0000000..8c92e96 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-cqrs/src/test/resources/application.yml @@ -0,0 +1,32 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + sql: + init: + mode: always + schema-locations: classpath:schema.sql + h2: + console: + enabled: true + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration + - org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration + - org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration + - org.springframework.boot.autoconfigure.data.mongo.MongoRepositoriesAutoConfiguration + +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + id-type: auto + +# CQRS 配置:指定使用 MYBATIS_PLUS 作为写代理类型 +structure: + infra: + type: MYBATIS_PLUS \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-cqrs/src/test/resources/schema.sql b/structure-infra-sample/structure-infra-sample-cqrs/src/test/resources/schema.sql new file mode 100644 index 0000000..e52866f --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-cqrs/src/test/resources/schema.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS t_user ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(255) NOT NULL, + password VARCHAR(255), + email VARCHAR(255), + age INT, + create_time TIMESTAMP, + update_time TIMESTAMP +); \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/pom.xml b/structure-infra-sample/structure-infra-sample-elasticsearch/pom.xml new file mode 100644 index 0000000..9d6ab73 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + + cn.structured + structure-infra-sample + ${revision} + ../pom.xml + + + structure-infra-sample-elasticsearch + structure-infra-sample-elasticsearch + Elasticsearch 示例模块 - 演示 Elasticsearch 仓储实现 + jar + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-elasticsearch + + + + + cn.structured + structure-infra-sample-core + ${revision} + + + + + cn.structured + structure-infra-elasticsearch-starter + ${revision} + + + + + cn.structured + structure-common + + + cn.structured + structure-security-jwt-starter + + + + org.springframework.boot + spring-boot-starter-test + test + + + + diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/ElasticsearchSampleApplication.java b/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/ElasticsearchSampleApplication.java new file mode 100644 index 0000000..03ef254 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/ElasticsearchSampleApplication.java @@ -0,0 +1,12 @@ +package cn.structure.infra.sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackages = "cn.structure.infra.sample") +public class ElasticsearchSampleApplication { + + public static void main(String[] args) { + SpringApplication.run(ElasticsearchSampleApplication.class, args); + } +} diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/elasticsearch/config/ElasticsearchConfig.java b/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/elasticsearch/config/ElasticsearchConfig.java new file mode 100644 index 0000000..6ee397d --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/elasticsearch/config/ElasticsearchConfig.java @@ -0,0 +1,33 @@ +package cn.structure.infra.sample.elasticsearch.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.data.elasticsearch.client.ClientConfiguration; +import org.springframework.data.elasticsearch.client.elc.ElasticsearchConfiguration; + +@Configuration +@Profile("!es-test") +public class ElasticsearchConfig extends ElasticsearchConfiguration { + + @Value("${spring.elasticsearch.uris}") + private String elasticsearchUris; + + @Value("${spring.elasticsearch.username:}") + private String username; + + @Value("${spring.elasticsearch.password:}") + private String password; + + @Override + public ClientConfiguration clientConfiguration() { + ClientConfiguration.MaybeSecureClientConfigurationBuilder builder = ClientConfiguration.builder() + .connectedTo(elasticsearchUris.replace("http://", "").replace("https://", "")); + + if (username != null && !username.isEmpty() && password != null && !password.isEmpty()) { + builder.withBasicAuth(username, password); + } + + return builder.build(); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/elasticsearch/controller/UserController.java b/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/elasticsearch/controller/UserController.java new file mode 100644 index 0000000..c4542b7 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/elasticsearch/controller/UserController.java @@ -0,0 +1,82 @@ +package cn.structure.infra.sample.elasticsearch.controller; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.sample.domain.entity.UserEntity; +import cn.structure.infra.sample.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserController { + + private final UserRepository userRepository; + + @PostMapping + public UserEntity createUser(@RequestBody UserEntity user) { + user.setCreateTime(LocalDateTime.now()); + user.setUpdateTime(LocalDateTime.now()); + return userRepository.save(user); + } + + @GetMapping("/{id}") + public UserEntity getUserById(@PathVariable("id") Long id) { + return userRepository.findById(id); + } + + @GetMapping("/name/{username}") + public UserEntity getUserByName(@PathVariable("username") String username) { + return userRepository.findByName(username); + } + + @GetMapping("/list") + public List listUsers() { + return userRepository.queryList(null); + } + + @GetMapping("/page") + public ResPage pageUsers(@RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "size", defaultValue = "10") int size) { + ReqPage reqPage = new ReqPage(); + reqPage.setPage(page); + reqPage.setSize(size); + return userRepository.queryPage(reqPage); + } + + @PutMapping("/{id}") + public UserEntity updateUser(@PathVariable Long id, @RequestBody UserEntity user) { + UserEntity exist = userRepository.findById(id); + if (exist == null) { + throw new RuntimeException("用户不存在"); + } + user.setId(id); + user.setUpdateTime(LocalDateTime.now()); + return userRepository.save(user); + } + + @DeleteMapping("/{id}") + public String deleteUser(@PathVariable Long id) { + userRepository.removeById(id); + return "删除成功"; + } + + @PostMapping("/batch") + public List batchCreate(@RequestBody List users) { + LocalDateTime now = LocalDateTime.now(); + users.forEach(u -> { + u.setCreateTime(now); + u.setUpdateTime(now); + }); + return userRepository.saveBatch(users); + } + + @GetMapping("/count") + public long countUsers() { + return userRepository.count(null); + } +} diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/infra/repository/UserElasticsearchRepositoryImpl.java b/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/infra/repository/UserElasticsearchRepositoryImpl.java new file mode 100644 index 0000000..5be19be --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/infra/repository/UserElasticsearchRepositoryImpl.java @@ -0,0 +1,19 @@ +package cn.structure.infra.sample.infra.repository; + +import cn.structure.infra.annotations.Repository; +import cn.structure.infra.repository.RepositoryType; +import cn.structure.infra.sample.domain.entity.UserEntity; +import cn.structure.infra.sample.infra.po.UserPO; +import org.springframework.stereotype.Component; + +/** + * 用户仓储 Elasticsearch 实现 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@Repository(value = "用户仓储", type = RepositoryType.ELASTICSEARCH, entity = UserEntity.class, po = UserPO.class) +@Component("userRepository") +public class UserElasticsearchRepositoryImpl extends AbstractUserRepositoryImpl { +} diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/infra/repository/elasticsearch/UserEsDelegate.java b/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/infra/repository/elasticsearch/UserEsDelegate.java new file mode 100644 index 0000000..a4ef840 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/java/cn/structure/infra/sample/infra/repository/elasticsearch/UserEsDelegate.java @@ -0,0 +1,31 @@ +package cn.structure.infra.sample.infra.repository.elasticsearch; + +import cn.structure.infra.annotations.DelegateFor; +import cn.structure.infra.elasticsearch.repository.ElasticsearchRepositoryDelegate; +import cn.structure.infra.repository.RepositoryType; +import cn.structure.infra.sample.infra.po.UserPO; +import cn.structure.infra.sample.infra.repository.delegate.UserRepositoryDelegate; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.stereotype.Component; + +@Component +@DelegateFor( + name = "userRepository", + type = RepositoryType.ELASTICSEARCH, + po = UserPO.class, + description = "用户仓储 Elasticsearch 实现", + priority = 10 +) +@Slf4j +public class UserEsDelegate extends ElasticsearchRepositoryDelegate implements UserRepositoryDelegate { + + @Override + public UserPO finByName(String name) { + log.info("使用 Elasticsearch 实现 finByName"); + UserPO condition = new UserPO(); + condition.setUsername(name); + return queryOne(condition); + } + +} diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/resources/application.yml b/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/resources/application.yml new file mode 100644 index 0000000..df7170f --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/src/main/resources/application.yml @@ -0,0 +1,33 @@ +server: + port: 8082 + +structure: + infra: + type: ELASTICSEARCH +# 数据范围管理配置 + data-scope: + enabled: true + auto-filter-response: true + scan-packages: + - cn.structured.datascope.example.elasticsearch.dto + field-config: + org-id-field: orgId + dept-id-field: deptId + security: + enabled: true + antMatchers: + unAuthenticated: + - /** +spring: + main: + allow-circular-references: true + elasticsearch: + uris: http://172.24.20.15:9200 + username: elastic + password: Elastic_aXbnMp + +logging: + level: + org.springframework.data.elasticsearch: DEBUG + cn.structure.infra.repository: DEBUG + cn.structure.infra.elasticsearch: INFO diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/UserElasticsearchDetailRepositoryTest.java b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/UserElasticsearchDetailRepositoryTest.java new file mode 100644 index 0000000..861a4e1 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/UserElasticsearchDetailRepositoryTest.java @@ -0,0 +1,205 @@ +package cn.structure.infra.sample.elasticsearch; + +import cn.structure.infra.sample.domain.entity.UserEntity; +import cn.structure.infra.sample.domain.repository.UserRepository; +import cn.structure.infra.sample.elasticsearch.config.ElasticsearchTestConfig; +import cn.structure.infra.sample.elasticsearch.config.MockElasticsearchConfiguration; +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(classes = ElasticsearchTestConfig.class) +@ActiveProfiles("es-test") +@Import(MockElasticsearchConfiguration.class) +@DisplayName("Elasticsearch 仓储详细测试") +class UserElasticsearchDetailRepositoryTest { + + @Autowired + private UserRepository userRepository; + + private UserEntity createUser(String username, String email, Integer age) { + UserEntity user = new UserEntity(); + user.setUsername(username); + user.setEmail(email); + user.setAge(age); + user.setPassword("123456"); + user.setCreateTime(LocalDateTime.now()); + user.setUpdateTime(LocalDateTime.now()); + return user; + } + + @Test + @DisplayName("测试保存用户") + void testSave() { + UserEntity user = createUser("zhangsan", "zhangsan@example.com", 25); + UserEntity saved = userRepository.save(user); + + assertNotNull(saved); + assertNotNull(saved.getId()); + assertEquals("zhangsan", saved.getUsername()); + assertEquals("zhangsan@example.com", saved.getEmail()); + assertEquals(25, saved.getAge()); + } + + @Test + @DisplayName("测试根据ID查询") + void testFindById() { + UserEntity user = createUser("lisi", "lisi@example.com", 30); + UserEntity saved = userRepository.save(user); + + UserEntity found = userRepository.findById(saved.getId()); + assertNotNull(found); + assertEquals(saved.getId(), found.getId()); + assertEquals("lisi", found.getUsername()); + } + + @Test + @DisplayName("测试 queryById") + void testQueryById() { + UserEntity user = createUser("wangwu", "wangwu@example.com", 28); + UserEntity saved = userRepository.save(user); + + UserEntity found = userRepository.queryById(saved.getId()); + assertNotNull(found); + assertEquals("wangwu", found.getUsername()); + } + + @Test + @DisplayName("测试 queryByIdOptional - 存在") + void testQueryByIdOptional_Exists() { + UserEntity user = createUser("zhaoliu", "zhaoliu@example.com", 35); + UserEntity saved = userRepository.save(user); + + Optional optional = userRepository.queryByIdOptional(saved.getId()); + assertTrue(optional.isPresent()); + assertEquals("zhaoliu", optional.get().getUsername()); + } + + @Test + @DisplayName("测试 queryByIdOptional - 不存在") + void testQueryByIdOptional_NotExists() { + Optional optional = userRepository.queryByIdOptional(9999L); + assertFalse(optional.isPresent()); + } + + @Test + @DisplayName("测试 queryList - 查询全部") + void testQueryList_All() { + int beforeCount = userRepository.queryList(null).size(); + + userRepository.save(createUser("listUser1", "list1@test.com", 20)); + userRepository.save(createUser("listUser2", "list2@test.com", 25)); + userRepository.save(createUser("listUser3", "list3@test.com", 30)); + + List list = userRepository.queryList(null); + assertEquals(beforeCount + 3, list.size()); + } + + @Test + @DisplayName("测试 queryPage - 分页查询") + void testQueryPage() { + for (int i = 1; i <= 15; i++) { + userRepository.save(createUser("pageUser" + i, "page" + i + "@test.com", 20 + i)); + } + + ReqPage reqPage = new ReqPage(); + reqPage.setPage(2); + reqPage.setSize(5); + + ResPage page = userRepository.queryPage(reqPage); + + assertNotNull(page); + assertEquals(2, page.getCurrent()); + assertEquals(5, page.getSize()); + assertTrue(page.getTotal() >= 15); + assertEquals(5, page.getRecords().size()); + } + + @Test + @DisplayName("测试删除用户") + void testRemoveById() { + UserEntity user = createUser("delUser", "del@test.com", 20); + UserEntity saved = userRepository.save(user); + assertNotNull(userRepository.findById(saved.getId())); + + userRepository.removeById(saved.getId()); + assertNull(userRepository.findById(saved.getId())); + } + + @Test + @DisplayName("测试 Entity <-> PO 转换") + void testEntityPoConversion() { + UserEntity user = createUser("convertUser", "convert@test.com", 28); + UserEntity saved = userRepository.save(user); + + UserEntity found = userRepository.findById(saved.getId()); + assertNotNull(found); + assertEquals(saved.getId(), found.getId()); + assertEquals(saved.getUsername(), found.getUsername()); + assertEquals(saved.getEmail(), found.getEmail()); + assertEquals(saved.getAge(), found.getAge()); + } + + @Test + @DisplayName("测试批量保存") + void testSaveBatch() { + UserEntity user1 = createUser("batch1", "batch1@test.com", 20); + UserEntity user2 = createUser("batch2", "batch2@test.com", 25); + UserEntity user3 = createUser("batch3", "batch3@test.com", 30); + + List savedList = userRepository.saveBatch(List.of(user1, user2, user3)); + assertNotNull(savedList); + assertEquals(3, savedList.size()); + savedList.forEach(u -> assertNotNull(u.getId())); + } + + @Test + @DisplayName("测试批量删除") + void testRemoveBatchByIds() { + UserEntity user1 = userRepository.save(createUser("batchDel1", "bd1@test.com", 20)); + UserEntity user2 = userRepository.save(createUser("batchDel2", "bd2@test.com", 25)); + + assertNotNull(userRepository.findById(user1.getId())); + assertNotNull(userRepository.findById(user2.getId())); + + userRepository.removeBatchByIds(List.of(user1.getId(), user2.getId())); + + assertNull(userRepository.findById(user1.getId())); + assertNull(userRepository.findById(user2.getId())); + } + + @Test + @DisplayName("测试根据ID列表查询") + void testListByIds() { + UserEntity user1 = userRepository.save(createUser("listId1", "lid1@test.com", 20)); + UserEntity user2 = userRepository.save(createUser("listId2", "lid2@test.com", 25)); + UserEntity user3 = userRepository.save(createUser("listId3", "lid3@test.com", 30)); + + List list = userRepository.listByIds(List.of(user1.getId(), user3.getId())); + assertNotNull(list); + assertEquals(2, list.size()); + } + + @Test + @DisplayName("测试 count - 全部数量") + void testCount_All() { + long beforeCount = userRepository.count(null); + + userRepository.save(createUser("count1", "count1@test.com", 20)); + userRepository.save(createUser("count2", "count2@test.com", 25)); + + long afterCount = userRepository.count(null); + assertEquals(beforeCount + 2, afterCount); + } +} diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/UserElasticsearchRepositoryTest.java b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/UserElasticsearchRepositoryTest.java new file mode 100644 index 0000000..dcbe92d --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/UserElasticsearchRepositoryTest.java @@ -0,0 +1,35 @@ +package cn.structure.infra.sample.elasticsearch; + +import cn.structure.infra.sample.domain.entity.UserEntity; +import cn.structure.infra.sample.domain.repository.UserRepository; +import cn.structure.infra.sample.elasticsearch.config.ElasticsearchTestConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(classes = ElasticsearchTestConfig.class) +@ActiveProfiles("es-test") +@DisplayName("Elasticsearch 仓储测试") +class UserElasticsearchRepositoryTest { + + @Autowired(required = false) + private UserRepository userRepository; + + @Test + @DisplayName("测试 Elasticsearch 仓储注入") + void testElasticsearchRepositoryInjection() { + assertNotNull(userRepository, "Elasticsearch 仓储应该被成功注入"); + System.out.println("✓ Elasticsearch 仓储注入成功: " + userRepository.getClass().getName()); + } + + @Test + @DisplayName("测试 Elasticsearch Delegate 类型") + void testElasticsearchDelegateType() { + assertNotNull(userRepository, "Elasticsearch 仓储应该被注入"); + System.out.println("✓ Elasticsearch 仓储实现类: " + userRepository.getClass().getName()); + } +} diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/config/ElasticsearchLowCodeTestConfig.java b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/config/ElasticsearchLowCodeTestConfig.java new file mode 100644 index 0000000..4120fb4 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/config/ElasticsearchLowCodeTestConfig.java @@ -0,0 +1,19 @@ +package cn.structure.infra.sample.elasticsearch.config; + +import cn.structure.infra.lowcode.configuration.LowCodeAutoConfiguration; +import cn.structure.infra.elasticsearch.lowcode.ElasticsearchLowCodeAutoConfiguration; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Import; + +/** + * Elasticsearch 低代码测试配置 + *

+ * 用于测试 Elasticsearch 低代码仓储功能,导入低代码相关配置。 + */ +@SpringBootApplication +@Import({ + LowCodeAutoConfiguration.class, + ElasticsearchLowCodeAutoConfiguration.class +}) +public class ElasticsearchLowCodeTestConfig { +} diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/config/ElasticsearchTestConfig.java b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/config/ElasticsearchTestConfig.java new file mode 100644 index 0000000..e695740 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/config/ElasticsearchTestConfig.java @@ -0,0 +1,29 @@ +package cn.structure.infra.sample.elasticsearch.config; + +import cn.structure.infra.elasticsearch.configuration.ElasticsearchAutoConfiguration; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; + +@Configuration +@SpringBootApplication(excludeName = { + "org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration", + "com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration", + "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration", + "org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration", + "org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration", + "org.springframework.boot.autoconfigure.data.mongo.MongoRepositoriesAutoConfiguration" +}) +@ComponentScan(basePackages = { + "cn.structure.infra.sample", + "cn.structure.infra.repository" +}, excludeFilters = { + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "cn.structure.infra.sample.infra.repository.mybatis.*"), + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "cn.structure.infra.sample.infra.repository.jpa.*"), + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "cn.structure.infra.sample.infra.repository.mongodb.*") +}) +@Import(ElasticsearchAutoConfiguration.class) +public class ElasticsearchTestConfig { +} diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/config/MockElasticsearchConfiguration.java b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/config/MockElasticsearchConfiguration.java new file mode 100644 index 0000000..0b8e328 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/config/MockElasticsearchConfiguration.java @@ -0,0 +1,187 @@ +package cn.structure.infra.sample.elasticsearch.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.domain.Pageable; +import org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.data.elasticsearch.core.SearchHitsImpl; +import org.springframework.data.elasticsearch.core.TotalHitsRelation; +import org.springframework.data.elasticsearch.core.query.Query; + +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@TestConfiguration +public class MockElasticsearchConfiguration { + + private final Map, Map> dataStore = new HashMap<>(); + private long idGenerator = 1; + + public void reset() { + dataStore.clear(); + idGenerator = 1; + } + + @Bean + @Primary + public ElasticsearchTemplate elasticsearchTemplate() { + ElasticsearchTemplate template = mock(ElasticsearchTemplate.class); + + when(template.save(any(Object.class))).thenAnswer(invocation -> { + Object po = invocation.getArgument(0); + Class poClass = po.getClass(); + + Map classStore = dataStore.computeIfAbsent(poClass, k -> new HashMap<>()); + + try { + Field idField = findIdField(poClass); + if (idField != null) { + idField.setAccessible(true); + Object idValue = idField.get(po); + Long id = null; + + if (idValue == null || (idValue instanceof Number && ((Number) idValue).longValue() == 0)) { + id = idGenerator++; + setIdValue(po, id); + } else if (idValue instanceof Long) { + id = (Long) idValue; + } else if (idValue instanceof String) { + id = Long.valueOf((String) idValue); + } else if (idValue instanceof Number) { + id = ((Number) idValue).longValue(); + } + + if (id != null) { + classStore.put(id, po); + } + } + } catch (Exception ignored) { + } + + return po; + }); + + when(template.get(anyString(), any(Class.class))).thenAnswer(invocation -> { + String id = invocation.getArgument(0); + Class poClass = invocation.getArgument(1); + Map classStore = dataStore.get(poClass); + return classStore != null ? classStore.get(Long.valueOf(id)) : null; + }); + + when(template.delete(anyString(), any(Class.class))).thenAnswer(invocation -> { + String id = invocation.getArgument(0); + Class poClass = invocation.getArgument(1); + Map classStore = dataStore.get(poClass); + if (classStore != null) { + classStore.remove(Long.valueOf(id)); + } + return id; + }); + + when(template.search(any(Query.class), any(Class.class))).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + Class poClass = invocation.getArgument(1); + + List allData = getAllData(poClass); + + Pageable pageable = query.getPageable(); + List pageData = new ArrayList<>(allData); + if (pageable != null && pageable.isPaged()) { + int pageNum = pageable.getPageNumber(); + int pageSize = pageable.getPageSize(); + int fromIndex = pageNum * pageSize; + int toIndex = Math.min(fromIndex + pageSize, allData.size()); + if (fromIndex >= allData.size()) { + pageData = new ArrayList<>(); + } else { + pageData = new ArrayList<>(allData.subList(fromIndex, toIndex)); + } + } + + List> searchHits = new ArrayList<>(); + for (Object po : pageData) { + SearchHit hit = mock(SearchHit.class); + try { + Field idField = findIdField(poClass); + if (idField != null) { + idField.setAccessible(true); + Object idValue = idField.get(po); + when(hit.getId()).thenReturn(String.valueOf(idValue)); + } + } catch (Exception ignored) { + } + when(hit.getContent()).thenReturn(po); + searchHits.add(hit); + } + + return new SearchHitsImpl<>( + allData.size(), + TotalHitsRelation.EQUAL_TO, + 0.0f, + Duration.ZERO, + null, + null, + searchHits, + null, + null, + null + ); + }); + + when(template.count(any(Query.class), any(Class.class))).thenAnswer(invocation -> { + Class poClass = invocation.getArgument(1); + Map classStore = dataStore.get(poClass); + return classStore != null ? (long) classStore.size() : 0L; + }); + + return template; + } + + private List getAllData(Class poClass) { + Map classStore = dataStore.get(poClass); + return classStore != null ? new ArrayList<>(classStore.values()) : new ArrayList<>(); + } + + private Field findIdField(Class clazz) { + return findField(clazz, "id"); + } + + private Field findField(Class clazz, String fieldName) { + try { + return clazz.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + if (clazz.getSuperclass() != null && clazz.getSuperclass() != Object.class) { + return findField(clazz.getSuperclass(), fieldName); + } + return null; + } + } + + private void setIdValue(Object po, Long id) throws Exception { + Field field = findIdField(po.getClass()); + if (field != null) { + field.setAccessible(true); + if (field.getType() == Long.class || field.getType() == long.class) { + field.set(po, id); + } else if (field.getType() == Integer.class || field.getType() == int.class) { + field.set(po, id.intValue()); + } else if (field.getType() == String.class) { + field.set(po, String.valueOf(id)); + } else { + field.set(po, id); + } + } + } +} diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/lowcode/ElasticsearchLowCodeRepositoryTest.java b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/lowcode/ElasticsearchLowCodeRepositoryTest.java new file mode 100644 index 0000000..30f58fe --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/lowcode/ElasticsearchLowCodeRepositoryTest.java @@ -0,0 +1,710 @@ +package cn.structure.infra.sample.elasticsearch.lowcode; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.lowcode.model.AutoFillType; +import cn.structure.infra.lowcode.model.FieldSchema; +import cn.structure.infra.lowcode.model.FieldType; +import cn.structure.infra.lowcode.model.RepositoryConfig; +import cn.structure.infra.lowcode.model.ResourceSchema; +import cn.structure.infra.lowcode.model.StorageType; +import cn.structure.infra.lowcode.repository.LowCodeRepository; +import cn.structure.infra.lowcode.router.LowCodeRepositoryRouter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.domain.Pageable; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.IndexOperations; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; +import org.springframework.data.elasticsearch.core.query.IndexQuery; +import org.springframework.data.elasticsearch.core.query.Query; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * Elasticsearch 低代码仓储测试 - 通过 LowCodeRepository 接口测试 + *

+ * 验证 LowCodeRepositoryRouter 和 Elasticsearch 低代码存储的完整集成。 + * + * @author chuck + * @since 2026/6/29 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("Elasticsearch 低代码仓储测试 - LowCodeRepository 接口") +class ElasticsearchLowCodeRepositoryTest { + + @Mock + private ElasticsearchOperations elasticsearchOperations; + + @Mock + private IndexOperations indexOperations; + + private LowCodeRepository lowCodeRepository; + + private final Map>> docStore = new LinkedHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + private static final String RESOURCE_NAME = "article"; + private static final String INDEX_NAME = "t_lowcode_article"; + + @BeforeEach + void setUp() { + docStore.clear(); + idGenerator.set(1); + setupMock(); + + ResourceSchema schema = ResourceSchema.builder() + .resourceName(RESOURCE_NAME) + .tableName(INDEX_NAME) + .build(); + + schema.addField(FieldSchema.builder() + .name("id") + .type(FieldType.STRING) + .primaryKey(true) + .build()); + + schema.addField(FieldSchema.builder() + .name("title") + .type(FieldType.STRING) + .length(200) + .nullable(false) + .build()); + + schema.addField(FieldSchema.builder() + .name("content") + .type(FieldType.TEXT) + .nullable(false) + .build()); + + schema.addField(FieldSchema.builder() + .name("author") + .type(FieldType.STRING) + .length(50) + .nullable(false) + .index(true) + .build()); + + schema.addField(FieldSchema.builder() + .name("category") + .type(FieldType.STRING) + .length(50) + .index(true) + .build()); + + schema.addField(FieldSchema.builder() + .name("viewCount") + .type(FieldType.LONG) + .defaultValue("0") + .build()); + + schema.addField(FieldSchema.builder() + .name("status") + .type(FieldType.INTEGER) + .defaultValue("1") + .build()); + + schema.addField(FieldSchema.builder() + .name("created_at") + .type(FieldType.DATETIME) + .autoFill(AutoFillType.CREATE) + .build()); + + schema.addField(FieldSchema.builder() + .name("updated_at") + .type(FieldType.DATETIME) + .autoFill(AutoFillType.CREATE_UPDATE) + .build()); + + RepositoryConfig config = new RepositoryConfig(); + config.setType(StorageType.ELASTICSEARCH); + + cn.structure.infra.lowcode.repository.LowCodeRepoFactory factory = + new cn.structure.infra.elasticsearch.lowcode.ElasticsearchLowCodeRepoFactory(elasticsearchOperations); + + List factories = new ArrayList<>(); + factories.add(factory); + + LowCodeRepositoryRouter router = new LowCodeRepositoryRouter(factories); + router.registerResource(RESOURCE_NAME, schema, config); + lowCodeRepository = router; + } + + @SuppressWarnings("unchecked") + private void setupMock() { + when(elasticsearchOperations.indexOps(any(IndexCoordinates.class))).thenReturn(indexOperations); + when(indexOperations.exists()).thenReturn(true); + + when(elasticsearchOperations.index(any(IndexQuery.class), any(IndexCoordinates.class))).thenAnswer(invocation -> { + IndexQuery indexQuery = invocation.getArgument(0); + String id = indexQuery.getId(); + Object object = indexQuery.getObject(); + + if (object instanceof Map) { + Map doc = new LinkedHashMap<>((Map) object); + if (id == null || id.isEmpty()) { + id = String.valueOf(idGenerator.getAndIncrement()); + } + doc.put("id", id); + docStore.computeIfAbsent(INDEX_NAME, k -> new LinkedHashMap<>()).put(id, doc); + return id; + } + return id != null ? id : String.valueOf(idGenerator.getAndIncrement()); + }); + + when(elasticsearchOperations.get(anyString(), any(Class.class), any(IndexCoordinates.class))).thenAnswer(invocation -> { + String id = invocation.getArgument(0); + Map> index = docStore.get(INDEX_NAME); + if (index != null) { + Map doc = index.get(id); + if (doc != null) { + return new LinkedHashMap<>(doc); + } + } + return null; + }); + + when(elasticsearchOperations.delete(anyString(), any(IndexCoordinates.class))).thenAnswer(invocation -> { + String id = invocation.getArgument(0); + Map> index = docStore.get(INDEX_NAME); + if (index != null) { + index.remove(id); + } + return id; + }); + + when(elasticsearchOperations.search(any(Query.class), any(Class.class), any(IndexCoordinates.class))).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + Map queryParams = extractQueryParams(query); + + Map> index = docStore.get(INDEX_NAME); + List> allDocs = new ArrayList<>(); + if (index != null) { + allDocs.addAll(index.values()); + } + List> filtered = filterDocs(allDocs, queryParams); + + Pageable pageable = query.getPageable(); + long total = filtered.size(); + + List> pageContent = new ArrayList<>(); + if (pageable != null && pageable.isPaged()) { + int from = (int) pageable.getOffset(); + int to = Math.min(from + pageable.getPageSize(), filtered.size()); + if (from < filtered.size()) { + pageContent.addAll(filtered.subList(from, to)); + } + } else { + pageContent.addAll(filtered); + } + + List>> searchHits = new ArrayList<>(); + for (Map doc : pageContent) { + SearchHit> hit = mock(SearchHit.class); + when(hit.getContent()).thenReturn(doc); + searchHits.add(hit); + } + + SearchHits> result = mock(SearchHits.class); + when(result.getSearchHits()).thenReturn(searchHits); + when(result.getTotalHits()).thenReturn(total); + return result; + }); + + when(elasticsearchOperations.count(any(Query.class), any(IndexCoordinates.class))).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + Map queryParams = extractQueryParams(query); + Map> index = docStore.get(INDEX_NAME); + List> allDocs = new ArrayList<>(); + if (index != null) { + allDocs.addAll(index.values()); + } + List> filtered = filterDocs(allDocs, queryParams); + return (long) filtered.size(); + }); + } + + @SuppressWarnings("unchecked") + private Map extractQueryParams(Query query) { + Map params = new LinkedHashMap<>(); + try { + Field criteriaField = null; + Class clazz = query.getClass(); + while (clazz != null && clazz != Object.class) { + try { + criteriaField = clazz.getDeclaredField("criteria"); + break; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + if (criteriaField != null) { + criteriaField.setAccessible(true); + Object criteria = criteriaField.get(query); + if (criteria != null) { + extractCriteriaFields(criteria, params); + } + } + } catch (Exception ignored) { + } + return params; + } + + private void extractCriteriaFields(Object criteria, Map params) { + try { + String fieldName = null; + Class clazz = criteria.getClass(); + Field field = null; + while (clazz != null && clazz != Object.class) { + try { + field = clazz.getDeclaredField("field"); + break; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + if (field != null) { + field.setAccessible(true); + Object fieldVal = field.get(criteria); + if (fieldVal != null) { + fieldName = fieldVal.toString(); + } + } + + Object value = getCriteriaValue(criteria); + if (fieldName != null && value != null && !"_id".equals(fieldName)) { + params.put(fieldName, value); + } + + try { + Field subCriteriaField = null; + Class c = criteria.getClass(); + while (c != null && c != Object.class) { + try { + subCriteriaField = c.getDeclaredField("subCriteria"); + break; + } catch (NoSuchFieldException e) { + c = c.getSuperclass(); + } + } + if (subCriteriaField != null) { + subCriteriaField.setAccessible(true); + Object subCriteria = subCriteriaField.get(criteria); + if (subCriteria instanceof Iterable) { + for (Object sub : (Iterable) subCriteria) { + extractCriteriaFields(sub, params); + } + } + } + } catch (Exception ignored) { + } + } catch (Exception ignored) { + } + } + + private Object getCriteriaValue(Object criteria) { + try { + Class clazz = criteria.getClass(); + Field entriesField = null; + while (clazz != null && clazz != Object.class) { + try { + entriesField = clazz.getDeclaredField("queryCriteriaEntries"); + break; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + if (entriesField != null) { + entriesField.setAccessible(true); + Object entries = entriesField.get(criteria); + if (entries instanceof Iterable) { + for (Object entry : (Iterable) entries) { + Field valueField = null; + Class entryClazz = entry.getClass(); + while (entryClazz != null && entryClazz != Object.class) { + try { + valueField = entryClazz.getDeclaredField("value"); + break; + } catch (NoSuchFieldException e) { + entryClazz = entryClazz.getSuperclass(); + } + } + if (valueField != null) { + valueField.setAccessible(true); + Object value = valueField.get(entry); + if (value != null) { + return value; + } + } + } + } + } + } catch (Exception ignored) { + } + return null; + } + + private List> filterDocs(List> docs, Map params) { + if (params == null || params.isEmpty()) { + return new ArrayList<>(docs); + } + List> result = new ArrayList<>(); + for (Map doc : docs) { + boolean match = true; + for (Map.Entry entry : params.entrySet()) { + Object docValue = doc.get(entry.getKey()); + Object paramValue = entry.getValue(); + if (docValue == null || !docValue.equals(paramValue)) { + match = false; + break; + } + } + if (match) { + result.add(doc); + } + } + return result; + } + + // ==================== 测试方法 ==================== + + @Test + @DisplayName("测试保存文章(新增)- LowCodeRepository") + void testSave_Insert() { + Map article = new LinkedHashMap<>(); + article.put("title", "Test Article Title"); + article.put("content", "This is test article content."); + article.put("author", "test_author"); + article.put("category", "tech"); + article.put("viewCount", 100L); + article.put("status", 1); + + Map result = lowCodeRepository.save(RESOURCE_NAME, article); + + assertNotNull(result); + assertNotNull(result.get("id"), "ID 应该自动生成"); + assertEquals("Test Article Title", result.get("title")); + assertEquals("test_author", result.get("author")); + assertNotNull(result.get("created_at"), "应该自动填充创建时间"); + assertNotNull(result.get("updated_at"), "应该自动填充更新时间"); + } + + @Test + @DisplayName("测试保存文章(更新)- LowCodeRepository") + void testSave_Update() { + Map article = new LinkedHashMap<>(); + article.put("title", "Original Title"); + article.put("content", "Original content"); + article.put("author", "original_author"); + article.put("category", "news"); + + Map saved = lowCodeRepository.save(RESOURCE_NAME, article); + String id = (String) saved.get("id"); + + Map updateData = new LinkedHashMap<>(); + updateData.put("id", id); + updateData.put("title", "Updated Title"); + updateData.put("viewCount", 200L); + + Map updated = lowCodeRepository.save(RESOURCE_NAME, updateData); + + assertEquals(id, updated.get("id")); + assertEquals("Updated Title", updated.get("title")); + assertEquals(200L, updated.get("viewCount")); + } + + @Test + @DisplayName("测试根据ID查询 - LowCodeRepository") + void testFindById() { + Map article = new LinkedHashMap<>(); + article.put("title", "Find By Id Test"); + article.put("content", "Content for find by id test"); + article.put("author", "test_author"); + + Map saved = lowCodeRepository.save(RESOURCE_NAME, article); + String id = (String) saved.get("id"); + + Map result = lowCodeRepository.findById(RESOURCE_NAME, id); + + assertNotNull(result); + assertEquals(id, result.get("id")); + assertEquals("Find By Id Test", result.get("title")); + } + + @Test + @DisplayName("测试条件查询单条 - LowCodeRepository") + void testQueryOne() { + Map article1 = new LinkedHashMap<>(); + article1.put("title", "Article One"); + article1.put("content", "Content one"); + article1.put("author", "author_1"); + article1.put("category", "tech"); + lowCodeRepository.save(RESOURCE_NAME, article1); + + Map article2 = new LinkedHashMap<>(); + article2.put("title", "Article Two"); + article2.put("content", "Content two"); + article2.put("author", "author_2"); + article2.put("category", "news"); + lowCodeRepository.save(RESOURCE_NAME, article2); + + Map params = new LinkedHashMap<>(); + params.put("category", "tech"); + + Map result = lowCodeRepository.queryOne(RESOURCE_NAME, params); + + assertNotNull(result); + assertEquals("Article One", result.get("title")); + assertEquals("tech", result.get("category")); + } + + @Test + @DisplayName("测试条件查询列表 - LowCodeRepository") + void testQueryList() { + for (int i = 1; i <= 5; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Tech Article " + i); + article.put("content", "Tech content " + i); + article.put("author", "tech_author"); + article.put("category", i <= 3 ? "tech" : "news"); + lowCodeRepository.save(RESOURCE_NAME, article); + } + + Map params = new LinkedHashMap<>(); + params.put("category", "tech"); + + List> results = lowCodeRepository.queryList(RESOURCE_NAME, params); + + assertNotNull(results); + assertEquals(3, results.size()); + } + + @Test + @DisplayName("测试分页查询 - LowCodeRepository") + void testQueryPage() { + for (int i = 1; i <= 25; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Page Article " + i); + article.put("content", "Page content " + i); + article.put("author", "page_author"); + article.put("category", "page_category"); + lowCodeRepository.save(RESOURCE_NAME, article); + } + + ReqPage reqPage = new ReqPage(); + reqPage.setPage(1); + reqPage.setSize(10); + ResPage> result = lowCodeRepository.queryPage(RESOURCE_NAME, reqPage); + + assertNotNull(result); + assertEquals(25L, result.getTotal()); + assertEquals(10, result.getRecords().size()); + } + + @Test + @DisplayName("测试删除文章 - LowCodeRepository") + void testRemoveById() { + Map article = new LinkedHashMap<>(); + article.put("title", "Delete Test Article"); + article.put("content", "Content to delete"); + article.put("author", "delete_author"); + + Map saved = lowCodeRepository.save(RESOURCE_NAME, article); + String id = (String) saved.get("id"); + + lowCodeRepository.removeById(RESOURCE_NAME, id); + + Map result = lowCodeRepository.findById(RESOURCE_NAME, id); + assertNull(result); + } + + @Test + @DisplayName("测试批量保存 - LowCodeRepository") + void testSaveBatch() { + List> articles = new ArrayList<>(); + for (int i = 1; i <= 5; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Batch Article " + i); + article.put("content", "Batch content " + i); + article.put("author", "batch_author"); + articles.add(article); + } + + List> results = lowCodeRepository.saveBatch(RESOURCE_NAME, articles); + + assertNotNull(results); + assertEquals(5, results.size()); + for (Map r : results) { + assertNotNull(r.get("id")); + } + } + + @Test + @DisplayName("测试批量删除 - LowCodeRepository") + void testRemoveBatchByIds() { + List ids = new ArrayList<>(); + for (int i = 1; i <= 3; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Batch Remove " + i); + article.put("content", "Content " + i); + article.put("author", "batch_remove_author"); + Map saved = lowCodeRepository.save(RESOURCE_NAME, article); + ids.add(saved.get("id")); + } + + lowCodeRepository.removeBatchByIds(RESOURCE_NAME, ids); + + for (Object id : ids) { + Map result = lowCodeRepository.findById(RESOURCE_NAME, id); + assertNull(result); + } + } + + @Test + @DisplayName("测试批量查询 - LowCodeRepository") + void testListByIds() { + List ids = new ArrayList<>(); + for (int i = 1; i <= 3; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "List By Ids " + i); + article.put("content", "Content " + i); + article.put("author", "list_author"); + Map saved = lowCodeRepository.save(RESOURCE_NAME, article); + ids.add(saved.get("id")); + } + + List> results = lowCodeRepository.listByIds(RESOURCE_NAME, ids); + + assertNotNull(results); + assertEquals(3, results.size()); + } + + @Test + @DisplayName("测试统计数量 - LowCodeRepository") + void testCount() { + for (int i = 1; i <= 5; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Count Article " + i); + article.put("content", "Count content " + i); + article.put("author", "count_author"); + article.put("category", i <= 3 ? "tech" : "news"); + lowCodeRepository.save(RESOURCE_NAME, article); + } + + long count = lowCodeRepository.count(RESOURCE_NAME, null); + assertEquals(5, count); + + Map params = new LinkedHashMap<>(); + params.put("category", "tech"); + long conditionCount = lowCodeRepository.count(RESOURCE_NAME, params); + assertEquals(3, conditionCount); + } + + @Test + @DisplayName("测试判断存在 - LowCodeRepository") + void testExists() { + Map article = new LinkedHashMap<>(); + article.put("title", "Exists Test Article"); + article.put("content", "Content for exists test"); + article.put("author", "exists_author"); + article.put("category", "exists_category"); + lowCodeRepository.save(RESOURCE_NAME, article); + + Map params = new LinkedHashMap<>(); + params.put("author", "exists_author"); + boolean exists = lowCodeRepository.exists(RESOURCE_NAME, params); + assertTrue(exists); + + params.put("author", "not_exists_author"); + boolean notExists = lowCodeRepository.exists(RESOURCE_NAME, params); + assertFalse(notExists); + } + + @Test + @DisplayName("测试自动填充时间字段 - LowCodeRepository") + void testAutoFill() { + Map article = new LinkedHashMap<>(); + article.put("title", "AutoFill Test Article"); + article.put("content", "Content for auto fill test"); + article.put("author", "autofill_author"); + + Map result = lowCodeRepository.save(RESOURCE_NAME, article); + + assertNotNull(result.get("created_at")); + assertNotNull(result.get("updated_at")); + } + + @Test + @DisplayName("测试 queryById - LowCodeRepository") + void testQueryById() { + Map article = new LinkedHashMap<>(); + article.put("title", "QueryById Test"); + article.put("content", "Content for query by id test"); + article.put("author", "query_author"); + + Map saved = lowCodeRepository.save(RESOURCE_NAME, article); + String id = (String) saved.get("id"); + + Map result = lowCodeRepository.queryById(RESOURCE_NAME, id); + + assertNotNull(result); + assertEquals(id, result.get("id")); + assertEquals("QueryById Test", result.get("title")); + } + + @Test + @DisplayName("测试 queryByIdOptional - LowCodeRepository") + void testQueryByIdOptional() { + Map article = new LinkedHashMap<>(); + article.put("title", "Optional Test"); + article.put("content", "Content for optional test"); + article.put("author", "optional_author"); + + Map saved = lowCodeRepository.save(RESOURCE_NAME, article); + String id = (String) saved.get("id"); + + Optional> result = lowCodeRepository.queryByIdOptional(RESOURCE_NAME, id); + + assertTrue(result.isPresent()); + assertEquals(id, result.get().get("id")); + } + + @Test + @DisplayName("测试 queryOneOptional - LowCodeRepository") + void testQueryOneOptional() { + Map article = new LinkedHashMap<>(); + article.put("title", "QueryOneOptional Test"); + article.put("content", "Content"); + article.put("author", "qopt_author"); + article.put("category", "qopt_cat"); + lowCodeRepository.save(RESOURCE_NAME, article); + + Map params = new LinkedHashMap<>(); + params.put("category", "qopt_cat"); + + Optional> result = lowCodeRepository.queryOneOptional(RESOURCE_NAME, params); + + assertTrue(result.isPresent()); + assertEquals("QueryOneOptional Test", result.get().get("title")); + } +} diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/lowcode/ElasticsearchLowCodeStorageTest.java b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/lowcode/ElasticsearchLowCodeStorageTest.java new file mode 100644 index 0000000..4a8d573 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/java/cn/structure/infra/sample/elasticsearch/lowcode/ElasticsearchLowCodeStorageTest.java @@ -0,0 +1,617 @@ +package cn.structure.infra.sample.elasticsearch.lowcode; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.elasticsearch.lowcode.ElasticsearchLowCodeStorage; +import cn.structure.infra.lowcode.model.AutoFillType; +import cn.structure.infra.lowcode.model.FieldSchema; +import cn.structure.infra.lowcode.model.FieldType; +import cn.structure.infra.lowcode.model.ResourceSchema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.domain.Pageable; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.IndexOperations; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates; +import org.springframework.data.elasticsearch.core.query.IndexQuery; +import org.springframework.data.elasticsearch.core.query.Query; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("Elasticsearch 低代码仓储单元测试") +class ElasticsearchLowCodeStorageTest { + + @Mock + private ElasticsearchOperations elasticsearchOperations; + + @Mock + private IndexOperations indexOperations; + + private ElasticsearchLowCodeStorage storage; + + private final Map> docStore = new LinkedHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + private static final String INDEX_NAME = "t_lowcode_article"; + + @BeforeEach + void setUp() { + ResourceSchema schema = ResourceSchema.builder() + .resourceName("article") + .tableName(INDEX_NAME) + .build(); + + schema.addField(FieldSchema.builder() + .name("id") + .type(FieldType.STRING) + .primaryKey(true) + .build()); + + schema.addField(FieldSchema.builder() + .name("title") + .type(FieldType.STRING) + .length(200) + .nullable(false) + .build()); + + schema.addField(FieldSchema.builder() + .name("content") + .type(FieldType.TEXT) + .nullable(false) + .build()); + + schema.addField(FieldSchema.builder() + .name("author") + .type(FieldType.STRING) + .length(50) + .nullable(false) + .index(true) + .build()); + + schema.addField(FieldSchema.builder() + .name("category") + .type(FieldType.STRING) + .length(50) + .index(true) + .build()); + + schema.addField(FieldSchema.builder() + .name("viewCount") + .type(FieldType.LONG) + .defaultValue("0") + .build()); + + schema.addField(FieldSchema.builder() + .name("status") + .type(FieldType.INTEGER) + .defaultValue("1") + .build()); + + schema.addField(FieldSchema.builder() + .name("created_at") + .type(FieldType.DATETIME) + .autoFill(AutoFillType.CREATE) + .build()); + + schema.addField(FieldSchema.builder() + .name("updated_at") + .type(FieldType.DATETIME) + .autoFill(AutoFillType.CREATE_UPDATE) + .build()); + + storage = new ElasticsearchLowCodeStorage(schema, elasticsearchOperations); + setupMock(); + } + + @SuppressWarnings("unchecked") + private void setupMock() { + docStore.clear(); + idGenerator.set(1); + + when(elasticsearchOperations.indexOps(any(IndexCoordinates.class))).thenReturn(indexOperations); + when(indexOperations.exists()).thenReturn(true); + + when(elasticsearchOperations.index(any(IndexQuery.class), any(IndexCoordinates.class))).thenAnswer(invocation -> { + IndexQuery indexQuery = invocation.getArgument(0); + String id = indexQuery.getId(); + Object object = indexQuery.getObject(); + + if (object instanceof Map) { + Map doc = new LinkedHashMap<>((Map) object); + if (id == null || id.isEmpty()) { + id = String.valueOf(idGenerator.getAndIncrement()); + } + doc.put("id", id); + docStore.put(id, doc); + return id; + } + return id != null ? id : String.valueOf(idGenerator.getAndIncrement()); + }); + + when(elasticsearchOperations.get(anyString(), any(Class.class), any(IndexCoordinates.class))).thenAnswer(invocation -> { + String id = invocation.getArgument(0); + Map doc = docStore.get(id); + if (doc != null) { + return new LinkedHashMap<>(doc); + } + return null; + }); + + when(elasticsearchOperations.delete(anyString(), any(IndexCoordinates.class))).thenAnswer(invocation -> { + String id = invocation.getArgument(0); + docStore.remove(id); + return id; + }); + + when(elasticsearchOperations.search(any(Query.class), any(Class.class), any(IndexCoordinates.class))).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + Map queryParams = extractQueryParams(query); + + List> allDocs = new ArrayList<>(docStore.values()); + List> filtered = filterDocs(allDocs, queryParams); + + Pageable pageable = query.getPageable(); + long total = filtered.size(); + + List> pageContent = new ArrayList<>(); + if (pageable != null && pageable.isPaged()) { + int from = (int) pageable.getOffset(); + int to = Math.min(from + pageable.getPageSize(), filtered.size()); + if (from < filtered.size()) { + pageContent.addAll(filtered.subList(from, to)); + } + } else { + pageContent.addAll(filtered); + } + + List>> searchHits = new ArrayList<>(); + for (Map doc : pageContent) { + @SuppressWarnings("unchecked") + SearchHit> hit = mock(SearchHit.class); + when(hit.getContent()).thenReturn(doc); + searchHits.add(hit); + } + + SearchHits> result = mock(SearchHits.class); + when(result.getSearchHits()).thenReturn(searchHits); + when(result.getTotalHits()).thenReturn(total); + return result; + }); + + when(elasticsearchOperations.count(any(Query.class), any(IndexCoordinates.class))).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + Map queryParams = extractQueryParams(query); + List> allDocs = new ArrayList<>(docStore.values()); + List> filtered = filterDocs(allDocs, queryParams); + return (long) filtered.size(); + }); + } + + @SuppressWarnings("unchecked") + private Map extractQueryParams(Query query) { + Map params = new LinkedHashMap<>(); + try { + Field criteriaField = null; + Class clazz = query.getClass(); + while (clazz != null && clazz != Object.class) { + try { + criteriaField = clazz.getDeclaredField("criteria"); + break; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + if (criteriaField != null) { + criteriaField.setAccessible(true); + Object criteria = criteriaField.get(query); + if (criteria != null) { + extractCriteriaFields(criteria, params); + } + } + } catch (Exception ignored) { + } + return params; + } + + private void extractCriteriaFields(Object criteria, Map params) { + try { + String fieldName = null; + Class clazz = criteria.getClass(); + Field field = null; + while (clazz != null && clazz != Object.class) { + try { + field = clazz.getDeclaredField("field"); + break; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + if (field != null) { + field.setAccessible(true); + Object fieldVal = field.get(criteria); + if (fieldVal != null) { + fieldName = fieldVal.toString(); + } + } + + Object value = getCriteriaValue(criteria); + if (fieldName != null && value != null && !"_id".equals(fieldName)) { + params.put(fieldName, value); + } + + try { + Field subCriteriaField = null; + Class c = criteria.getClass(); + while (c != null && c != Object.class) { + try { + subCriteriaField = c.getDeclaredField("subCriteria"); + break; + } catch (NoSuchFieldException e) { + c = c.getSuperclass(); + } + } + if (subCriteriaField != null) { + subCriteriaField.setAccessible(true); + Object subCriteria = subCriteriaField.get(criteria); + if (subCriteria instanceof Iterable) { + for (Object sub : (Iterable) subCriteria) { + extractCriteriaFields(sub, params); + } + } + } + } catch (Exception ignored) { + } + } catch (Exception ignored) { + } + } + + private Object getCriteriaValue(Object criteria) { + try { + Class clazz = criteria.getClass(); + Field entriesField = null; + while (clazz != null && clazz != Object.class) { + try { + entriesField = clazz.getDeclaredField("queryCriteriaEntries"); + break; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + if (entriesField != null) { + entriesField.setAccessible(true); + Object entries = entriesField.get(criteria); + if (entries instanceof Iterable) { + for (Object entry : (Iterable) entries) { + Field valueField = null; + Class entryClazz = entry.getClass(); + while (entryClazz != null && entryClazz != Object.class) { + try { + valueField = entryClazz.getDeclaredField("value"); + break; + } catch (NoSuchFieldException e) { + entryClazz = entryClazz.getSuperclass(); + } + } + if (valueField != null) { + valueField.setAccessible(true); + Object value = valueField.get(entry); + if (value != null) { + return value; + } + } + } + } + } + } catch (Exception ignored) { + } + return null; + } + + private List> filterDocs(List> docs, Map params) { + if (params == null || params.isEmpty()) { + return new ArrayList<>(docs); + } + List> result = new ArrayList<>(); + for (Map doc : docs) { + boolean match = true; + for (Map.Entry entry : params.entrySet()) { + Object docValue = doc.get(entry.getKey()); + Object paramValue = entry.getValue(); + if (docValue == null || !docValue.equals(paramValue)) { + match = false; + break; + } + } + if (match) { + result.add(doc); + } + } + return result; + } + + @Test + @DisplayName("测试保存文章(新增)") + void testSave_Insert() { + Map article = new LinkedHashMap<>(); + article.put("title", "Test Article Title"); + article.put("content", "This is test article content."); + article.put("author", "test_author"); + article.put("category", "tech"); + article.put("viewCount", 100L); + article.put("status", 1); + + Map result = storage.save(article); + + assertNotNull(result); + assertNotNull(result.get("id"), "ID 应该自动生成"); + assertEquals("Test Article Title", result.get("title")); + assertEquals("test_author", result.get("author")); + assertNotNull(result.get("created_at"), "应该自动填充创建时间"); + assertNotNull(result.get("updated_at"), "应该自动填充更新时间"); + } + + @Test + @DisplayName("测试保存文章(更新)") + void testSave_Update() { + Map article = new LinkedHashMap<>(); + article.put("title", "Original Title"); + article.put("content", "Original content"); + article.put("author", "original_author"); + article.put("category", "news"); + + Map saved = storage.save(article); + String id = (String) saved.get("id"); + + Map updateData = new LinkedHashMap<>(); + updateData.put("id", id); + updateData.put("title", "Updated Title"); + updateData.put("viewCount", 200L); + + Map result = storage.save(updateData); + + assertEquals(id, result.get("id")); + assertEquals("Updated Title", result.get("title")); + assertEquals(200L, result.get("viewCount")); + } + + @Test + @DisplayName("测试根据ID查询") + void testFindById() { + Map article = new LinkedHashMap<>(); + article.put("title", "Find By Id Test"); + article.put("content", "Content for find by id test"); + article.put("author", "test_author"); + + Map saved = storage.save(article); + String id = (String) saved.get("id"); + + Map result = storage.findById(id); + + assertNotNull(result); + assertEquals(id, result.get("id")); + assertEquals("Find By Id Test", result.get("title")); + } + + @Test + @DisplayName("测试条件查询单条") + void testQueryOne() { + Map article1 = new LinkedHashMap<>(); + article1.put("title", "Article One"); + article1.put("content", "Content one"); + article1.put("author", "author_1"); + article1.put("category", "tech"); + storage.save(article1); + + Map article2 = new LinkedHashMap<>(); + article2.put("title", "Article Two"); + article2.put("content", "Content two"); + article2.put("author", "author_2"); + article2.put("category", "news"); + storage.save(article2); + + Map params = new LinkedHashMap<>(); + params.put("category", "tech"); + + Map result = storage.queryOne(params); + + assertNotNull(result); + assertEquals("Article One", result.get("title")); + assertEquals("tech", result.get("category")); + } + + @Test + @DisplayName("测试条件查询列表") + void testQueryList() { + for (int i = 1; i <= 5; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Tech Article " + i); + article.put("content", "Tech content " + i); + article.put("author", "tech_author"); + article.put("category", i <= 3 ? "tech" : "news"); + storage.save(article); + } + + Map params = new LinkedHashMap<>(); + params.put("category", "tech"); + + List> results = storage.queryList(params); + + assertNotNull(results); + assertEquals(3, results.size()); + } + + @Test + @DisplayName("测试分页查询") + void testQueryPage() { + for (int i = 1; i <= 25; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Page Article " + i); + article.put("content", "Page content " + i); + article.put("author", "page_author"); + article.put("category", "page_category"); + storage.save(article); + } + + ReqPage reqPage = new ReqPage(); + reqPage.setPage(1); + reqPage.setSize(10); + ResPage> result = storage.queryPage(reqPage); + + assertNotNull(result); + assertEquals(25L, result.getTotal()); + assertEquals(10, result.getRecords().size()); + } + + @Test + @DisplayName("测试删除文章") + void testRemoveById() { + Map article = new LinkedHashMap<>(); + article.put("title", "Delete Test Article"); + article.put("content", "Content to delete"); + article.put("author", "delete_author"); + + Map saved = storage.save(article); + String id = (String) saved.get("id"); + + storage.removeById(id); + + Map result = storage.findById(id); + assertNull(result); + } + + @Test + @DisplayName("测试批量保存") + void testSaveBatch() { + List> articles = new ArrayList<>(); + for (int i = 1; i <= 5; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Batch Article " + i); + article.put("content", "Batch content " + i); + article.put("author", "batch_author"); + articles.add(article); + } + + List> results = storage.saveBatch(articles); + + assertNotNull(results); + assertEquals(5, results.size()); + for (Map r : results) { + assertNotNull(r.get("id")); + } + } + + @Test + @DisplayName("测试批量删除") + void testRemoveBatchByIds() { + List ids = new ArrayList<>(); + for (int i = 1; i <= 3; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Batch Remove " + i); + article.put("content", "Content " + i); + article.put("author", "batch_remove_author"); + Map saved = storage.save(article); + ids.add(saved.get("id")); + } + + storage.removeBatchByIds(ids); + + for (Object id : ids) { + Map result = storage.findById(id); + assertNull(result); + } + } + + @Test + @DisplayName("测试批量查询") + void testListByIds() { + List ids = new ArrayList<>(); + for (int i = 1; i <= 3; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "List By Ids " + i); + article.put("content", "Content " + i); + article.put("author", "list_author"); + Map saved = storage.save(article); + ids.add(saved.get("id")); + } + + List> results = storage.listByIds(ids); + + assertNotNull(results); + assertEquals(3, results.size()); + } + + @Test + @DisplayName("测试统计数量") + void testCount() { + for (int i = 1; i <= 5; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Count Article " + i); + article.put("content", "Count content " + i); + article.put("author", "count_author"); + article.put("category", i <= 3 ? "tech" : "news"); + storage.save(article); + } + + long count = storage.count(null); + assertEquals(5, count); + + Map params = new LinkedHashMap<>(); + params.put("category", "tech"); + long conditionCount = storage.count(params); + assertEquals(3, conditionCount); + } + + @Test + @DisplayName("测试判断存在") + void testExists() { + Map article = new LinkedHashMap<>(); + article.put("title", "Exists Test Article"); + article.put("content", "Content for exists test"); + article.put("author", "exists_author"); + article.put("category", "exists_category"); + storage.save(article); + + Map params = new LinkedHashMap<>(); + params.put("author", "exists_author"); + boolean exists = storage.exists(params); + assertTrue(exists); + + params.put("author", "not_exists_author"); + boolean notExists = storage.exists(params); + assertFalse(notExists); + } + + @Test + @DisplayName("测试自动填充时间字段") + void testAutoFill() { + Map article = new LinkedHashMap<>(); + article.put("title", "AutoFill Test Article"); + article.put("content", "Content for auto fill test"); + article.put("author", "autofill_author"); + + Map result = storage.save(article); + + assertNotNull(result.get("created_at")); + assertNotNull(result.get("updated_at")); + } +} diff --git a/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/resources/application-es-test.yml b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/resources/application-es-test.yml new file mode 100644 index 0000000..d5b013b --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-elasticsearch/src/test/resources/application-es-test.yml @@ -0,0 +1,15 @@ +structure: + infra: + type: ELASTICSEARCH + +spring: + elasticsearch: + uris: http://localhost:9200 + connection-timeout: 5s + socket-timeout: 30s + +logging: + level: + org.springframework.data.elasticsearch: DEBUG + cn.structure.infra.repository: DEBUG + cn.structure.infra.elasticsearch: INFO diff --git a/structure-infra-sample/structure-infra-sample-jpa/pom.xml b/structure-infra-sample/structure-infra-sample-jpa/pom.xml new file mode 100644 index 0000000..39bdc87 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-jpa/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + + cn.structured + structure-infra-sample + ${revision} + ../pom.xml + + + structure-infra-sample-jpa + structure-infra-sample-jpa + JPA 示例模块 - 演示 JPA 仓储实现 + jar + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + com.h2database + h2 + runtime + + + + + cn.structured + structure-infra-sample-core + ${revision} + + + + + cn.structured + structure-infra-jpa-starter + ${revision} + + + + + cn.structured + structure-common + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + diff --git a/structure-infra-sample/structure-infra-sample-jpa/src/main/java/cn/structure/infra/sample/infra/repository/UserJpaRepositoryImpl.java b/structure-infra-sample/structure-infra-sample-jpa/src/main/java/cn/structure/infra/sample/infra/repository/UserJpaRepositoryImpl.java new file mode 100644 index 0000000..b5fb7de --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-jpa/src/main/java/cn/structure/infra/sample/infra/repository/UserJpaRepositoryImpl.java @@ -0,0 +1,19 @@ +package cn.structure.infra.sample.infra.repository; + +import cn.structure.infra.annotations.Repository; +import cn.structure.infra.repository.RepositoryType; +import cn.structure.infra.sample.domain.entity.UserEntity; +import cn.structure.infra.sample.infra.po.UserPO; +import org.springframework.stereotype.Component; + +/** + * 用户仓储 JPA 实现 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@Repository(value = "用户仓储", type = RepositoryType.JPA, entity = UserEntity.class, po = UserPO.class) +@Component("userRepository") +public class UserJpaRepositoryImpl extends AbstractUserRepositoryImpl { +} diff --git a/structure-infra-sample/structure-infra-sample-jpa/src/main/java/cn/structure/infra/sample/infra/repository/jpa/UserJpaDelegate.java b/structure-infra-sample/structure-infra-sample-jpa/src/main/java/cn/structure/infra/sample/infra/repository/jpa/UserJpaDelegate.java new file mode 100644 index 0000000..a7bc0e9 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-jpa/src/main/java/cn/structure/infra/sample/infra/repository/jpa/UserJpaDelegate.java @@ -0,0 +1,28 @@ +package cn.structure.infra.sample.infra.repository.jpa; + +import cn.structure.infra.annotations.DelegateFor; +import cn.structure.infra.jpa.repository.JpaRepositoryDelegate; +import cn.structure.infra.repository.RepositoryType; +import cn.structure.infra.sample.infra.po.UserPO; +import cn.structure.infra.sample.infra.repository.delegate.UserRepositoryDelegate; +import jakarta.persistence.EntityManager; +import org.springframework.stereotype.Component; + +@Component +@DelegateFor( + name = "userRepository", + type = RepositoryType.JPA, + po = UserPO.class, + description = "用户仓储 JPA 实现", + priority = 10 +) +public class UserJpaDelegate extends JpaRepositoryDelegate implements UserRepositoryDelegate { + + @Override + public UserPO finByName(String name) { + UserPO condition = new UserPO(); + condition.setUsername(name); + return queryOne(condition); + } + +} diff --git a/structure-infra-sample/structure-infra-sample-jpa/src/test/java/cn/structure/infra/sample/jpa/UserJpaRepositoryTest.java b/structure-infra-sample/structure-infra-sample-jpa/src/test/java/cn/structure/infra/sample/jpa/UserJpaRepositoryTest.java new file mode 100644 index 0000000..67fa66c --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-jpa/src/test/java/cn/structure/infra/sample/jpa/UserJpaRepositoryTest.java @@ -0,0 +1,310 @@ +package cn.structure.infra.sample.jpa; + +import cn.structure.infra.sample.domain.entity.UserEntity; +import cn.structure.infra.sample.domain.repository.UserRepository; +import cn.structure.infra.sample.jpa.config.JpaTestConfig; +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(classes = JpaTestConfig.class) +@ActiveProfiles("jpa-test") +@Transactional +@DisplayName("JPA 仓储测试") +class UserJpaRepositoryTest { + + @Autowired + private UserRepository userRepository; + + private UserEntity createUser(String username, String email, Integer age) { + UserEntity user = new UserEntity(); + user.setUsername(username); + user.setEmail(email); + user.setAge(age); + user.setPassword("123456"); + user.setCreateTime(LocalDateTime.now()); + user.setUpdateTime(LocalDateTime.now()); + return user; + } + + @Test + @DisplayName("测试保存用户") + void testSave() { + UserEntity user = createUser("zhangsan", "zhangsan@example.com", 25); + UserEntity saved = userRepository.save(user); + + assertNotNull(saved); + assertNotNull(saved.getId()); + assertEquals("zhangsan", saved.getUsername()); + assertEquals("zhangsan@example.com", saved.getEmail()); + assertEquals(25, saved.getAge()); + + System.out.println("保存用户成功: " + saved); + } + + @Test + @DisplayName("测试根据ID查询") + void testFindById() { + UserEntity user = createUser("lisi", "lisi@example.com", 30); + UserEntity saved = userRepository.save(user); + + UserEntity found = userRepository.findById(saved.getId()); + assertNotNull(found); + assertEquals(saved.getId(), found.getId()); + assertEquals("lisi", found.getUsername()); + } + + @Test + @DisplayName("测试 queryById") + void testQueryById() { + UserEntity user = createUser("wangwu", "wangwu@example.com", 28); + UserEntity saved = userRepository.save(user); + + UserEntity found = userRepository.queryById(saved.getId()); + assertNotNull(found); + assertEquals("wangwu", found.getUsername()); + } + + @Test + @DisplayName("测试 queryByIdOptional - 存在") + void testQueryByIdOptional_Exists() { + UserEntity user = createUser("zhaoliu", "zhaoliu@example.com", 35); + UserEntity saved = userRepository.save(user); + + Optional optional = userRepository.queryByIdOptional(saved.getId()); + assertTrue(optional.isPresent()); + assertEquals("zhaoliu", optional.get().getUsername()); + } + + @Test + @DisplayName("测试 queryByIdOptional - 不存在") + void testQueryByIdOptional_NotExists() { + Optional optional = userRepository.queryByIdOptional(9999L); + assertFalse(optional.isPresent()); + } + + @Test + @DisplayName("测试 queryOne - 条件查询单条") + void testQueryOne() { + userRepository.save(createUser("user1", "user1@test.com", 20)); + userRepository.save(createUser("user2", "user2@test.com", 25)); + + UserEntity condition = new UserEntity(); + condition.setUsername("user1"); + + UserEntity found = userRepository.queryOne(condition); + assertNotNull(found); + assertEquals("user1", found.getUsername()); + assertEquals("user1@test.com", found.getEmail()); + } + + @Test + @DisplayName("测试 queryOneOptional") + void testQueryOneOptional() { + userRepository.save(createUser("optUser", "opt@test.com", 22)); + + UserEntity condition = new UserEntity(); + condition.setUsername("optUser"); + + Optional optional = userRepository.queryOneOptional(condition); + assertTrue(optional.isPresent()); + assertEquals("optUser", optional.get().getUsername()); + } + + @Test + @DisplayName("测试 queryOneOptional - 不存在") + void testQueryOneOptional_NotExists() { + UserEntity condition = new UserEntity(); + condition.setUsername("nonexistent"); + + Optional optional = userRepository.queryOneOptional(condition); + assertFalse(optional.isPresent()); + } + + @Test + @DisplayName("测试 queryList - 查询全部") + void testQueryList_All() { + int beforeCount = userRepository.queryList(null).size(); + + userRepository.save(createUser("listUser1", "list1@test.com", 20)); + userRepository.save(createUser("listUser2", "list2@test.com", 25)); + userRepository.save(createUser("listUser3", "list3@test.com", 30)); + + List list = userRepository.queryList(null); + assertEquals(beforeCount + 3, list.size()); + } + + @Test + @DisplayName("测试 queryList - 条件查询") + void testQueryList_ByCondition() { + userRepository.save(createUser("ageUser1", "age1@test.com", 18)); + userRepository.save(createUser("ageUser2", "age2@test.com", 25)); + userRepository.save(createUser("ageUser3", "age3@test.com", 18)); + + UserEntity condition = new UserEntity(); + condition.setAge(18); + + List list = userRepository.queryList(condition); + assertTrue(list.size() >= 2); + assertTrue(list.stream().allMatch(u -> u.getAge() == 18)); + } + + @Test + @DisplayName("测试 queryList - 列表返回空集合") + void testQueryList_Empty() { + UserEntity condition = new UserEntity(); + condition.setAge(999); + + List list = userRepository.queryList(condition); + assertNotNull(list); + assertTrue(list.isEmpty()); + } + + @Test + @DisplayName("测试 queryPage - 分页查询") + void testQueryPage() { + for (int i = 1; i <= 15; i++) { + userRepository.save(createUser("pageUser" + i, "page" + i + "@test.com", 20 + i)); + } + + ReqPage reqPage = new ReqPage(); + reqPage.setPage(2); + reqPage.setSize(5); + + ResPage page = userRepository.queryPage(reqPage); + + assertNotNull(page); + assertEquals(2, page.getCurrent()); + assertEquals(5, page.getSize()); + assertTrue(page.getTotal() >= 15); + assertEquals(5, page.getRecords().size()); + + System.out.println("分页查询结果: 当前页=" + page.getCurrent() + + ", 总页数=" + page.getPages() + + ", 每页=" + page.getSize() + + ", 总数=" + page.getTotal() + + ", 记录数=" + page.getRecords().size()); + } + + @Test + @DisplayName("测试删除用户") + void testRemoveById() { + UserEntity user = createUser("delUser", "del@test.com", 20); + UserEntity saved = userRepository.save(user); + assertNotNull(userRepository.findById(saved.getId())); + + userRepository.removeById(saved.getId()); + assertNull(userRepository.findById(saved.getId())); + } + + @Test + @DisplayName("测试 Entity <-> PO 转换") + void testEntityPoConversion() { + UserEntity user = createUser("convertUser", "convert@test.com", 28); + UserEntity saved = userRepository.save(user); + + UserEntity found = userRepository.findById(saved.getId()); + assertNotNull(found); + assertEquals(saved.getId(), found.getId()); + assertEquals(saved.getUsername(), found.getUsername()); + assertEquals(saved.getEmail(), found.getEmail()); + assertEquals(saved.getAge(), found.getAge()); + } + + @Test + @DisplayName("测试批量保存") + void testSaveBatch() { + UserEntity user1 = createUser("batch1", "batch1@test.com", 20); + UserEntity user2 = createUser("batch2", "batch2@test.com", 25); + UserEntity user3 = createUser("batch3", "batch3@test.com", 30); + + List savedList = userRepository.saveBatch(List.of(user1, user2, user3)); + assertNotNull(savedList); + assertEquals(3, savedList.size()); + savedList.forEach(u -> assertNotNull(u.getId())); + } + + @Test + @DisplayName("测试批量删除") + void testRemoveBatchByIds() { + UserEntity user1 = userRepository.save(createUser("batchDel1", "bd1@test.com", 20)); + UserEntity user2 = userRepository.save(createUser("batchDel2", "bd2@test.com", 25)); + + assertNotNull(userRepository.findById(user1.getId())); + assertNotNull(userRepository.findById(user2.getId())); + + userRepository.removeBatchByIds(List.of(user1.getId(), user2.getId())); + + assertNull(userRepository.findById(user1.getId())); + assertNull(userRepository.findById(user2.getId())); + } + + @Test + @DisplayName("测试根据ID列表查询") + void testListByIds() { + UserEntity user1 = userRepository.save(createUser("listId1", "lid1@test.com", 20)); + UserEntity user2 = userRepository.save(createUser("listId2", "lid2@test.com", 25)); + UserEntity user3 = userRepository.save(createUser("listId3", "lid3@test.com", 30)); + + List list = userRepository.listByIds(List.of(user1.getId(), user3.getId())); + assertNotNull(list); + assertEquals(2, list.size()); + } + + @Test + @DisplayName("测试 count - 全部数量") + void testCount_All() { + long beforeCount = userRepository.count(null); + + userRepository.save(createUser("count1", "count1@test.com", 20)); + userRepository.save(createUser("count2", "count2@test.com", 25)); + + long afterCount = userRepository.count(null); + assertEquals(beforeCount + 2, afterCount); + } + + @Test + @DisplayName("测试 count - 条件数量") + void testCount_ByCondition() { + userRepository.save(createUser("countAge1", "ca1@test.com", 22)); + userRepository.save(createUser("countAge2", "ca2@test.com", 22)); + userRepository.save(createUser("countAge3", "ca3@test.com", 33)); + + UserEntity condition = new UserEntity(); + condition.setAge(22); + + long count = userRepository.count(condition); + assertTrue(count >= 2); + } + + @Test + @DisplayName("测试 exists - 存在") + void testExists_True() { + UserEntity user = userRepository.save(createUser("existsUser", "exists@test.com", 20)); + + UserEntity condition = new UserEntity(); + condition.setUsername("existsUser"); + + assertTrue(userRepository.exists(condition)); + } + + @Test + @DisplayName("测试 exists - 不存在") + void testExists_False() { + UserEntity condition = new UserEntity(); + condition.setUsername("nonexistent_user"); + + assertFalse(userRepository.exists(condition)); + } +} diff --git a/structure-infra-sample/structure-infra-sample-jpa/src/test/java/cn/structure/infra/sample/jpa/config/JpaTestConfig.java b/structure-infra-sample/structure-infra-sample-jpa/src/test/java/cn/structure/infra/sample/jpa/config/JpaTestConfig.java new file mode 100644 index 0000000..4b0b8c0 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-jpa/src/test/java/cn/structure/infra/sample/jpa/config/JpaTestConfig.java @@ -0,0 +1,84 @@ +package cn.structure.infra.sample.jpa.config; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.SharedEntityManagerCreator; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import javax.sql.DataSource; +import java.util.Properties; + +@Configuration +@SpringBootApplication(excludeName = { + "org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration", + "com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration", + "org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration", + "org.springframework.boot.autoconfigure.data.mongo.MongoRepositoriesAutoConfiguration", + "org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchDataAutoConfiguration", + "org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchRepositoriesAutoConfiguration", + "org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration", + "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration", + "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration" +}) +@ComponentScan(basePackages = { + "cn.structure.infra.sample", + "cn.structure.infra.repository", + "cn.structure.infra.jpa" +}, excludeFilters = { + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "cn.structure.infra.sample.infra.repository.mybatis.*"), + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "cn.structure.infra.sample.infra.repository.mongodb.*"), + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "cn.structure.infra.sample.infra.repository.elasticsearch.*") +}) +@EnableTransactionManagement +public class JpaTestConfig { + + @Bean + public DataSource dataSource() { + return new EmbeddedDatabaseBuilder() + .setType(EmbeddedDatabaseType.H2) + .setName("jpa_testdb") + .build(); + } + + @Bean + public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) { + LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean(); + em.setDataSource(dataSource); + em.setPackagesToScan("cn.structure.infra.sample.infra.po"); + + HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter(); + em.setJpaVendorAdapter(vendorAdapter); + + Properties properties = new Properties(); + properties.setProperty("hibernate.hbm2ddl.auto", "create-drop"); + properties.setProperty("hibernate.dialect", "org.hibernate.dialect.H2Dialect"); + properties.setProperty("hibernate.show_sql", "true"); + properties.setProperty("hibernate.format_sql", "true"); + em.setJpaProperties(properties); + + return em; + } + + @Bean + public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) { + JpaTransactionManager transactionManager = new JpaTransactionManager(); + transactionManager.setEntityManagerFactory(entityManagerFactory); + return transactionManager; + } + + @Bean + public EntityManager entityManager(EntityManagerFactory entityManagerFactory) { + return SharedEntityManagerCreator.createSharedEntityManager(entityManagerFactory); + } +} diff --git a/structure-infra-sample/structure-infra-sample-jpa/src/test/resources/application-jpa-test.yml b/structure-infra-sample/structure-infra-sample-jpa/src/test/resources/application-jpa-test.yml new file mode 100644 index 0000000..014b310 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-jpa/src/test/resources/application-jpa-test.yml @@ -0,0 +1,32 @@ +structure: + infra: + type: JPA + +spring: + datasource: + url: jdbc:h2:mem:jpa_testdb + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + use_sql_comments: true + + h2: + console: + enabled: true + path: /h2-console + +logging: + level: + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + cn.structure.infra.repository: DEBUG + cn.structure.infra.jpa: DEBUG diff --git a/structure-infra-sample/structure-infra-sample-mongodb/pom.xml b/structure-infra-sample/structure-infra-sample-mongodb/pom.xml new file mode 100644 index 0000000..8d6d0b7 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mongodb/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + + cn.structured + structure-infra-sample + ${revision} + ../pom.xml + + + structure-infra-sample-mongodb + structure-infra-sample-mongodb + MongoDB 示例模块 - 演示 MongoDB 仓储实现 + jar + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + + + cn.structured + structure-infra-sample-core + ${revision} + + + + + cn.structured + structure-infra-mongodb-starter + ${revision} + + + + + cn.structured + structure-common + + + + cn.structured + structure-security-jwt-starter + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + diff --git a/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/infra/repository/UserMongoRepositoryImpl.java b/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/infra/repository/UserMongoRepositoryImpl.java new file mode 100644 index 0000000..e39c8e8 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/infra/repository/UserMongoRepositoryImpl.java @@ -0,0 +1,19 @@ +package cn.structure.infra.sample.infra.repository; + +import cn.structure.infra.annotations.Repository; +import cn.structure.infra.repository.RepositoryType; +import cn.structure.infra.sample.domain.entity.UserEntity; +import cn.structure.infra.sample.infra.po.UserPO; +import org.springframework.stereotype.Component; + +/** + * 用户仓储 MongoDB 实现 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@Repository(value = "用户仓储", type = RepositoryType.MONGODB, entity = UserEntity.class, po = UserPO.class) +@Component("userRepository") +public class UserMongoRepositoryImpl extends AbstractUserRepositoryImpl { +} diff --git a/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/infra/repository/mongodb/UserMongoDelegate.java b/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/infra/repository/mongodb/UserMongoDelegate.java new file mode 100644 index 0000000..923c4db --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/infra/repository/mongodb/UserMongoDelegate.java @@ -0,0 +1,27 @@ +package cn.structure.infra.sample.infra.repository.mongodb; + +import cn.structure.infra.annotations.DelegateFor; +import cn.structure.infra.mongodb.repository.MongoRepositoryDelegate; +import cn.structure.infra.repository.RepositoryType; +import cn.structure.infra.sample.infra.po.UserPO; +import cn.structure.infra.sample.infra.repository.delegate.UserRepositoryDelegate; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Component; + +@Component +@DelegateFor( + name = "userRepository", + type = RepositoryType.MONGODB, + po = UserPO.class, + description = "用户仓储 MongoDB 实现", + priority = 10 +) +public class UserMongoDelegate extends MongoRepositoryDelegate implements UserRepositoryDelegate { + + @Override + public UserPO finByName(String name) { + UserPO condition = new UserPO(); + condition.setUsername(name); + return queryOne(condition); + } +} diff --git a/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/mongodb/MongoSampleApplication.java b/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/mongodb/MongoSampleApplication.java new file mode 100644 index 0000000..1711a55 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/mongodb/MongoSampleApplication.java @@ -0,0 +1,12 @@ +package cn.structure.infra.sample.mongodb; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackages = "cn.structure.infra.sample") +public class MongoSampleApplication { + + public static void main(String[] args) { + SpringApplication.run(MongoSampleApplication.class, args); + } +} diff --git a/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/mongodb/config/MongoConfig.java b/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/mongodb/config/MongoConfig.java new file mode 100644 index 0000000..15ac9d2 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/mongodb/config/MongoConfig.java @@ -0,0 +1,24 @@ +package cn.structure.infra.sample.mongodb.config; + +import com.mongodb.client.MongoClients; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; + +@Configuration +@Profile("!mongo-test") +public class MongoConfig { + + @Bean + public SimpleMongoClientDatabaseFactory mongoDatabaseFactory() { + String connectionString = "mongodb://user:123456@172.24.20.15:27017/test?authSource=admin&authMechanism=SCRAM-SHA-1"; + return new SimpleMongoClientDatabaseFactory(MongoClients.create(connectionString), "test"); + } + + @Bean + public MongoTemplate mongoTemplate(SimpleMongoClientDatabaseFactory mongoDatabaseFactory) { + return new MongoTemplate(mongoDatabaseFactory); + } +} diff --git a/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/mongodb/controller/UserController.java b/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/mongodb/controller/UserController.java new file mode 100644 index 0000000..521d5fc --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mongodb/src/main/java/cn/structure/infra/sample/mongodb/controller/UserController.java @@ -0,0 +1,82 @@ +package cn.structure.infra.sample.mongodb.controller; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.sample.domain.entity.UserEntity; +import cn.structure.infra.sample.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +@RestController +@RequestMapping("/api/users") +@RequiredArgsConstructor +public class UserController { + + private final UserRepository userRepository; + + @PostMapping + public UserEntity createUser(@RequestBody UserEntity user) { + user.setCreateTime(LocalDateTime.now()); + user.setUpdateTime(LocalDateTime.now()); + return userRepository.save(user); + } + + @GetMapping("/{id}") + public UserEntity getUserById(@PathVariable("id") Long id) { + return userRepository.findById(id); + } + + @GetMapping("/name/{username}") + public UserEntity getUserByName(@PathVariable("username") String username) { + return userRepository.findByName(username); + } + + @GetMapping("/list") + public List listUsers() { + return userRepository.queryList(null); + } + + @GetMapping("/page") + public ResPage pageUsers(@RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size) { + ReqPage reqPage = new ReqPage(); + reqPage.setPage(page); + reqPage.setSize(size); + return userRepository.queryPage(reqPage); + } + + @PutMapping("/{id}") + public UserEntity updateUser(@PathVariable("id") Long id, @RequestBody UserEntity user) { + UserEntity exist = userRepository.findById(id); + if (exist == null) { + throw new RuntimeException("用户不存在"); + } + user.setId(id); + user.setUpdateTime(LocalDateTime.now()); + return userRepository.save(user); + } + + @DeleteMapping("/{id}") + public String deleteUser(@PathVariable Long id) { + userRepository.removeById(id); + return "删除成功"; + } + + @PostMapping("/batch") + public List batchCreate(@RequestBody List users) { + LocalDateTime now = LocalDateTime.now(); + users.forEach(u -> { + u.setCreateTime(now); + u.setUpdateTime(now); + }); + return userRepository.saveBatch(users); + } + + @GetMapping("/count") + public long countUsers() { + return userRepository.count(null); + } +} diff --git a/structure-infra-sample/structure-infra-sample-mongodb/src/main/resources/application.yml b/structure-infra-sample/structure-infra-sample-mongodb/src/main/resources/application.yml new file mode 100644 index 0000000..45c61bc --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mongodb/src/main/resources/application.yml @@ -0,0 +1,33 @@ +server: + port: 8081 + +structure: + infra: + type: MONGODB + data-scope: + field-config: + org-id-field: orgId + dept-id-field: deptId + user-id-field: userId + enabled: true + scan-packages: + - cn.structured.datascope.example.mongodb + security: + enabled: true + antMatchers: + unAuthenticated: + - /** +spring: + application: + name: mongodb-example + main: + allow-circular-references: true + mongodb: + uri: mongodb://user:123456@172.24.20.15:27017/test?authSource=admin&authMechanism=SCRAM-SHA-1 + + +logging: + level: + org.springframework.data.mongodb: DEBUG + cn.structure.infra.repository: DEBUG + cn.structure.infra.mongodb: INFO diff --git a/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/UserMongoRepositoryInjectionTest.java b/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/UserMongoRepositoryInjectionTest.java new file mode 100644 index 0000000..9b1ea04 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/UserMongoRepositoryInjectionTest.java @@ -0,0 +1,34 @@ +package cn.structure.infra.sample.mongodb; + +import cn.structure.infra.sample.domain.repository.UserRepository; +import cn.structure.infra.sample.mongodb.config.MongoTestConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(classes = MongoTestConfig.class) +@ActiveProfiles("mongo-test") +@DisplayName("MongoDB 仓储基础测试 - 验证 Bean 注入") +class UserMongoRepositoryInjectionTest { + + @Autowired(required = false) + private UserRepository userRepository; + + @Test + @DisplayName("测试 MongoDB 仓储注入") + void testMongoRepositoryInjection() { + assertNotNull(userRepository, "MongoDB 仓储应该被成功注入"); + System.out.println("✓ MongoDB 仓储注入成功: " + userRepository.getClass().getName()); + } + + @Test + @DisplayName("测试 MongoDB Delegate 类型") + void testMongoDelegateType() { + assertNotNull(userRepository, "MongoDB 仓储应该被注入"); + System.out.println("✓ MongoDB 仓储实现类: " + userRepository.getClass().getName()); + } +} diff --git a/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/UserMongoRepositoryTest.java b/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/UserMongoRepositoryTest.java new file mode 100644 index 0000000..96c3424 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/UserMongoRepositoryTest.java @@ -0,0 +1,213 @@ +package cn.structure.infra.sample.mongodb; + +import cn.structure.infra.sample.domain.entity.UserEntity; +import cn.structure.infra.sample.domain.repository.UserRepository; +import cn.structure.infra.sample.mongodb.config.MongoTestConfig; +import cn.structure.infra.sample.mongodb.config.MockMongoConfiguration; +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(classes = MongoTestConfig.class) +@ActiveProfiles("mongo-test") +@Import(MockMongoConfiguration.class) +@DisplayName("MongoDB 仓储测试") +class UserMongoRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private MockMongoConfiguration mockMongoConfig; + + @BeforeEach + void setUp() { + mockMongoConfig.reset(); + } + + private UserEntity createUser(String username, String email, Integer age) { + UserEntity user = new UserEntity(); + user.setUsername(username); + user.setEmail(email); + user.setAge(age); + user.setPassword("123456"); + user.setCreateTime(LocalDateTime.now()); + user.setUpdateTime(LocalDateTime.now()); + return user; + } + + @Test + @DisplayName("测试保存用户") + void testSave() { + UserEntity user = createUser("zhangsan", "zhangsan@example.com", 25); + UserEntity saved = userRepository.save(user); + + assertNotNull(saved); + assertNotNull(saved.getId()); + assertEquals("zhangsan", saved.getUsername()); + assertEquals("zhangsan@example.com", saved.getEmail()); + assertEquals(25, saved.getAge()); + } + + @Test + @DisplayName("测试根据ID查询") + void testFindById() { + UserEntity user = createUser("lisi", "lisi@example.com", 30); + UserEntity saved = userRepository.save(user); + + UserEntity found = userRepository.findById(saved.getId()); + assertNotNull(found); + assertEquals(saved.getId(), found.getId()); + assertEquals("lisi", found.getUsername()); + } + + @Test + @DisplayName("测试 queryById") + void testQueryById() { + UserEntity user = createUser("wangwu", "wangwu@example.com", 28); + UserEntity saved = userRepository.save(user); + + UserEntity found = userRepository.queryById(saved.getId()); + assertNotNull(found); + assertEquals("wangwu", found.getUsername()); + } + + @Test + @DisplayName("测试 queryByIdOptional - 存在") + void testQueryByIdOptional_Exists() { + UserEntity user = createUser("zhaoliu", "zhaoliu@example.com", 35); + UserEntity saved = userRepository.save(user); + + Optional optional = userRepository.queryByIdOptional(saved.getId()); + assertTrue(optional.isPresent()); + assertEquals("zhaoliu", optional.get().getUsername()); + } + + @Test + @DisplayName("测试 queryByIdOptional - 不存在") + void testQueryByIdOptional_NotExists() { + Optional optional = userRepository.queryByIdOptional(9999L); + assertFalse(optional.isPresent()); + } + + @Test + @DisplayName("测试 queryList - 查询全部") + void testQueryList_All() { + int beforeCount = userRepository.queryList(null).size(); + + userRepository.save(createUser("listUser1", "list1@test.com", 20)); + userRepository.save(createUser("listUser2", "list2@test.com", 25)); + userRepository.save(createUser("listUser3", "list3@test.com", 30)); + + List list = userRepository.queryList(null); + assertEquals(beforeCount + 3, list.size()); + } + + @Test + @DisplayName("测试 queryPage - 分页查询") + void testQueryPage() { + for (int i = 1; i <= 15; i++) { + userRepository.save(createUser("pageUser" + i, "page" + i + "@test.com", 20 + i)); + } + + ReqPage reqPage = new ReqPage(); + reqPage.setPage(2); + reqPage.setSize(5); + + ResPage page = userRepository.queryPage(reqPage); + + assertNotNull(page); + assertEquals(2, page.getCurrent()); + assertEquals(5, page.getSize()); + assertTrue(page.getTotal() >= 15); + } + + @Test + @DisplayName("测试删除用户") + void testRemoveById() { + UserEntity user = createUser("delUser", "del@test.com", 20); + UserEntity saved = userRepository.save(user); + assertNotNull(userRepository.findById(saved.getId())); + + userRepository.removeById(saved.getId()); + assertNull(userRepository.findById(saved.getId())); + } + + @Test + @DisplayName("测试 Entity <-> PO 转换") + void testEntityPoConversion() { + UserEntity user = createUser("convertUser", "convert@test.com", 28); + UserEntity saved = userRepository.save(user); + + UserEntity found = userRepository.findById(saved.getId()); + assertNotNull(found); + assertEquals(saved.getId(), found.getId()); + assertEquals(saved.getUsername(), found.getUsername()); + assertEquals(saved.getEmail(), found.getEmail()); + assertEquals(saved.getAge(), found.getAge()); + } + + @Test + @DisplayName("测试批量保存") + void testSaveBatch() { + UserEntity user1 = createUser("batch1", "batch1@test.com", 20); + UserEntity user2 = createUser("batch2", "batch2@test.com", 25); + UserEntity user3 = createUser("batch3", "batch3@test.com", 30); + + List savedList = userRepository.saveBatch(List.of(user1, user2, user3)); + assertNotNull(savedList); + assertEquals(3, savedList.size()); + savedList.forEach(u -> assertNotNull(u.getId())); + } + + @Test + @DisplayName("测试批量删除") + void testRemoveBatchByIds() { + UserEntity user1 = userRepository.save(createUser("batchDel1", "bd1@test.com", 20)); + UserEntity user2 = userRepository.save(createUser("batchDel2", "bd2@test.com", 25)); + + assertNotNull(userRepository.findById(user1.getId())); + assertNotNull(userRepository.findById(user2.getId())); + + userRepository.removeBatchByIds(List.of(user1.getId(), user2.getId())); + + assertNull(userRepository.findById(user1.getId())); + assertNull(userRepository.findById(user2.getId())); + } + + @Test + @DisplayName("测试根据ID列表查询") + void testListByIds() { + UserEntity user1 = userRepository.save(createUser("listId1", "lid1@test.com", 20)); + UserEntity user2 = userRepository.save(createUser("listId2", "lid2@test.com", 25)); + UserEntity user3 = userRepository.save(createUser("listId3", "lid3@test.com", 30)); + + List list = userRepository.listByIds(List.of(user1.getId(), user3.getId())); + assertNotNull(list); + assertTrue(list.size() >= 2); + } + + @Test + @DisplayName("测试 count - 全部数量") + void testCount_All() { + long beforeCount = userRepository.count(null); + + userRepository.save(createUser("count1", "count1@test.com", 20)); + userRepository.save(createUser("count2", "count2@test.com", 25)); + + long afterCount = userRepository.count(null); + assertEquals(beforeCount + 2, afterCount); + } +} diff --git a/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/config/MockMongoConfiguration.java b/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/config/MockMongoConfiguration.java new file mode 100644 index 0000000..52af2e1 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/config/MockMongoConfiguration.java @@ -0,0 +1,516 @@ +package cn.structure.infra.sample.mongodb.config; + +import org.bson.Document; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; +import org.springframework.data.mongodb.core.convert.MongoConverter; +import org.springframework.data.mongodb.core.index.Index; +import org.springframework.data.mongodb.core.index.IndexOperations; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@TestConfiguration +public class MockMongoConfiguration { + + private final Map, Map> dataStore = new HashMap<>(); + private long idGenerator = 1; + + private final Map> docCollections = new ConcurrentHashMap<>(); + private final AtomicLong docIdGenerator = new AtomicLong(1); + private final List insertCalls = new ArrayList<>(); + + private final MongoMappingContext mappingContext = new MongoMappingContext(); + private final MongoConverter converter; + private final MongoTemplate template; + + public MockMongoConfiguration() { + this.converter = createMongoConverter(); + this.template = createMongoTemplate(); + } + + public void reset() { + dataStore.clear(); + idGenerator = 1; + docCollections.clear(); + docIdGenerator.set(1); + insertCalls.clear(); + } + + public List getInsertCalls() { + return insertCalls; + } + + private MongoConverter createMongoConverter() { + MappingMongoConverter converter = mock(MappingMongoConverter.class); + when(converter.getMappingContext()).thenReturn((MappingContext) mappingContext); + return converter; + } + + private MongoTemplate createMongoTemplate() { + MongoTemplate template = mock(MongoTemplate.class); + + when(template.save(any())).thenAnswer(invocation -> { + Object po = invocation.getArgument(0); + Class poClass = po.getClass(); + + Map classStore = dataStore.computeIfAbsent(poClass, k -> new HashMap<>()); + + try { + Field idField = findIdField(poClass); + if (idField != null) { + idField.setAccessible(true); + Object idValue = idField.get(po); + Long id = null; + + if (idValue == null || (idValue instanceof Number && ((Number) idValue).longValue() == 0)) { + id = idGenerator++; + setIdValue(po, id); + } else if (idValue instanceof Long) { + id = (Long) idValue; + } else if (idValue instanceof String) { + id = Long.valueOf((String) idValue); + } else if (idValue instanceof Number) { + id = ((Number) idValue).longValue(); + } + + if (id != null) { + classStore.put(id, po); + } + } + } catch (Exception ignored) { + } + + return po; + }); + + when(template.findById(any(Long.class), any(Class.class))).thenAnswer(invocation -> { + Long id = invocation.getArgument(0); + Class poClass = invocation.getArgument(1); + Map classStore = dataStore.get(poClass); + return classStore != null ? classStore.get(id) : null; + }); + + when(template.findById(anyString(), any(Class.class))).thenAnswer(invocation -> { + String id = invocation.getArgument(0); + Class poClass = invocation.getArgument(1); + Map classStore = dataStore.get(poClass); + return classStore != null ? classStore.get(Long.valueOf(id)) : null; + }); + + when(template.findOne(any(Query.class), any(Class.class))).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + Class poClass = invocation.getArgument(1); + List allData = getAllData(poClass); + List results = filterByQuery(query, allData); + return results.isEmpty() ? null : results.get(0); + }); + + when(template.findAll(any(Class.class))).thenAnswer(invocation -> { + Class poClass = invocation.getArgument(0); + return new ArrayList<>(getAllData(poClass)); + }); + + when(template.find(any(Query.class), any(Class.class))).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + Class poClass = invocation.getArgument(1); + List allData = getAllData(poClass); + return filterByQuery(query, allData); + }); + + when(template.remove(any(Query.class), any(Class.class))).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + Class poClass = invocation.getArgument(1); + List allData = getAllData(poClass); + List results = filterByQuery(query, allData); + + Map classStore = dataStore.get(poClass); + if (classStore != null) { + for (Object po : results) { + try { + Field idField = findIdField(poClass); + if (idField != null) { + idField.setAccessible(true); + Object idValue = idField.get(po); + Long id = null; + if (idValue instanceof Long) { + id = (Long) idValue; + } else if (idValue instanceof String) { + id = Long.valueOf((String) idValue); + } else if (idValue instanceof Number) { + id = ((Number) idValue).longValue(); + } + if (id != null) { + classStore.remove(id); + } + } + } catch (Exception ignored) { + } + } + } + return null; + }); + + when(template.count(any(Query.class), any(Class.class))).thenAnswer(invocation -> { + Class poClass = invocation.getArgument(1); + Map classStore = dataStore.get(poClass); + return classStore != null ? (long) classStore.size() : 0L; + }); + + // ---------- Document 版本 API(低代码仓储使用) ---------- + + // collectionExists + when(template.collectionExists(anyString())).thenAnswer(invocation -> { + String collectionName = invocation.getArgument(0); + return docCollections.containsKey(collectionName); + }); + + // createCollection + doAnswer(invocation -> { + String collectionName = invocation.getArgument(0); + docCollections.putIfAbsent(collectionName, new LinkedHashMap<>()); + return null; + }).when(template).createCollection(anyString()); + + // dropCollection + doAnswer(invocation -> { + String collectionName = invocation.getArgument(0); + docCollections.remove(collectionName); + return null; + }).when(template).dropCollection(anyString()); + + // indexOps + IndexOperations indexOps = mock(IndexOperations.class); + when(indexOps.ensureIndex(any(Index.class))).thenReturn(""); + when(template.indexOps(anyString())).thenReturn(indexOps); + + // findOne with collectionName + when(template.findOne(any(Query.class), any(Class.class), anyString())).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + String collectionName = invocation.getArgument(2); + Map collection = docCollections.get(collectionName); + if (collection == null || collection.isEmpty()) { + return null; + } + List allDocs = new ArrayList<>(collection.values()); + List filtered = filterDocsByQuery(query, allDocs); + return filtered.isEmpty() ? null : filtered.get(0); + }); + + // find with collectionName + when(template.find(any(Query.class), any(Class.class), anyString())).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + String collectionName = invocation.getArgument(2); + Map collection = docCollections.get(collectionName); + if (collection == null || collection.isEmpty()) { + return new ArrayList(); + } + List allDocs = new ArrayList<>(collection.values()); + return filterDocsByQuery(query, allDocs); + }); + + // insert with collectionName + when(template.insert(any(Document.class), anyString())).thenAnswer(invocation -> { + Document doc = invocation.getArgument(0); + String collectionName = invocation.getArgument(1); + insertCalls.add("insert(Document, String): " + collectionName); + Map collection = docCollections.computeIfAbsent(collectionName, k -> new LinkedHashMap<>()); + + String idField = "id"; + if (!doc.containsKey(idField) || doc.get(idField) == null) { + doc.put(idField, docIdGenerator.getAndIncrement()); + } + Object id = doc.get(idField); + collection.put(id, doc); + return doc; + }); + + // save with collectionName + when(template.save(any(Document.class), anyString())).thenAnswer(invocation -> { + Document doc = invocation.getArgument(0); + String collectionName = invocation.getArgument(1); + Map collection = docCollections.computeIfAbsent(collectionName, k -> new LinkedHashMap<>()); + + String idField = "id"; + if (!doc.containsKey(idField) || doc.get(idField) == null) { + doc.put(idField, docIdGenerator.getAndIncrement()); + } + Object id = doc.get(idField); + collection.put(id, doc); + return doc; + }); + + // updateFirst with collectionName + when(template.updateFirst(any(Query.class), any(Update.class), anyString())).thenAnswer(invocation -> { + String collectionName = invocation.getArgument(2); + Map collection = docCollections.get(collectionName); + if (collection == null || collection.isEmpty()) { + return null; + } + Document firstDoc = collection.values().iterator().next(); + Update update = invocation.getArgument(1); + Map updates = extractDocUpdateValues(update); + firstDoc.putAll(updates); + return null; + }); + + // remove with query and collectionName + doAnswer(invocation -> { + Query query = invocation.getArgument(0); + String collectionName = invocation.getArgument(1); + Map collection = docCollections.get(collectionName); + if (collection == null || collection.isEmpty()) { + return null; + } + List allDocs = new ArrayList<>(collection.values()); + List filtered = filterDocsByQuery(query, allDocs); + for (Document doc : filtered) { + Object id = doc.get("id"); + if (id != null) { + collection.remove(id); + } + } + return null; + }).when(template).remove(any(Query.class), anyString()); + + // count with collectionName + when(template.count(any(Query.class), anyString())).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + String collectionName = invocation.getArgument(1); + Map collection = docCollections.get(collectionName); + if (collection == null || collection.isEmpty()) { + return 0L; + } + List allDocs = new ArrayList<>(collection.values()); + List filtered = filterDocsByQuery(query, allDocs); + return (long) filtered.size(); + }); + + when(template.getConverter()).thenReturn(converter); + + return template; + } + + private List getAllData(Class poClass) { + Map classStore = dataStore.get(poClass); + return classStore != null ? new ArrayList<>(classStore.values()) : new ArrayList<>(); + } + + private List filterByQuery(Query query, List data) { + try { + Field criteriaField = Query.class.getDeclaredField("criteria"); + criteriaField.setAccessible(true); + Object criteriaObj = criteriaField.get(query); + + if (criteriaObj instanceof Criteria criteria) { + List> conditions = extractConditions(criteria); + return data.stream() + .filter(po -> matchesConditions(po, conditions)) + .collect(Collectors.toList()); + } + } catch (Exception ignored) { + } + return data; + } + + private List> extractConditions(Criteria criteria) { + List> conditions = new ArrayList<>(); + try { + Field keyField = Criteria.class.getDeclaredField("key"); + Field valueField = Criteria.class.getDeclaredField("value"); + keyField.setAccessible(true); + valueField.setAccessible(true); + + Object key = keyField.get(criteria); + Object value = valueField.get(criteria); + + if (key != null && value != null) { + conditions.add(Map.entry(key.toString(), value)); + } + } catch (Exception ignored) { + } + return conditions; + } + + private boolean matchesConditions(Object po, List> conditions) { + for (Map.Entry condition : conditions) { + String fieldName = condition.getKey(); + Object expectedValue = condition.getValue(); + + try { + Field field = findField(po.getClass(), fieldName); + if (field != null) { + field.setAccessible(true); + Object actualValue = field.get(po); + + if (!expectedValue.equals(actualValue)) { + return false; + } + } + } catch (Exception ignored) { + } + } + return true; + } + + private Field findField(Class clazz, String fieldName) { + try { + return clazz.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + if (clazz.getSuperclass() != null && clazz.getSuperclass() != Object.class) { + return findField(clazz.getSuperclass(), fieldName); + } + return null; + } + } + + private Field findIdField(Class clazz) { + return findField(clazz, "id"); + } + + private void setIdValue(Object po, Long id) throws Exception { + Field field = findIdField(po.getClass()); + if (field != null) { + field.setAccessible(true); + if (field.getType() == Long.class || field.getType() == long.class) { + field.set(po, id); + } else if (field.getType() == Integer.class || field.getType() == int.class) { + field.set(po, id.intValue()); + } else if (field.getType() == String.class) { + field.set(po, String.valueOf(id)); + } else { + field.set(po, id); + } + } + } + + /** + * 提取 Update 对象中的更新值(Document 版本) + */ + private Map extractDocUpdateValues(Update update) { + Map result = new HashMap<>(); + try { + Field updatesField = Update.class.getDeclaredField("updates"); + updatesField.setAccessible(true); + Object updates = updatesField.get(update); + if (updates instanceof List) { + for (Object u : (List) updates) { + try { + Field keyField = u.getClass().getDeclaredField("key"); + Field valueField = u.getClass().getDeclaredField("value"); + keyField.setAccessible(true); + valueField.setAccessible(true); + String key = (String) keyField.get(u); + Object value = valueField.get(u); + result.put(key, value); + } catch (Exception ignored) { + } + } + } + } catch (Exception ignored) { + } + return result; + } + + /** + * 根据 Query 条件过滤 Document 列表 + */ + private List filterDocsByQuery(Query query, List docs) { + try { + Field criteriaField = Query.class.getDeclaredField("criteria"); + criteriaField.setAccessible(true); + Object criteriaObj = criteriaField.get(query); + + if (criteriaObj instanceof Criteria criteria) { + List> conditions = extractDocConditions(criteria); + return docs.stream() + .filter(doc -> matchesDocConditions(doc, conditions)) + .collect(Collectors.toList()); + } + } catch (Exception ignored) { + } + return docs; + } + + /** + * 从 Criteria 中提取查询条件 + */ + private List> extractDocConditions(Criteria criteria) { + List> conditions = new ArrayList<>(); + try { + Field keyField = Criteria.class.getDeclaredField("key"); + Field valueField = Criteria.class.getDeclaredField("value"); + keyField.setAccessible(true); + valueField.setAccessible(true); + + Object key = keyField.get(criteria); + Object value = valueField.get(criteria); + + if (key != null && value != null) { + conditions.add(Map.entry(key.toString(), value)); + } + } catch (Exception ignored) { + } + return conditions; + } + + /** + * 判断 Document 是否匹配查询条件 + */ + private boolean matchesDocConditions(Document doc, List> conditions) { + for (Map.Entry condition : conditions) { + String fieldName = condition.getKey(); + Object expectedValue = condition.getValue(); + + Object actualValue = doc.get(fieldName); + if (actualValue == null || !actualValue.equals(expectedValue)) { + return false; + } + } + return true; + } + + @Bean + @Primary + public MongoDatabaseFactory mongoDatabaseFactory() { + return mock(MongoDatabaseFactory.class); + } + + @Bean + @Primary + public MongoMappingContext mongoMappingContext() { + return mappingContext; + } + + @Bean + @Primary + public MongoConverter mongoConverter() { + return converter; + } + + @Bean + @Primary + public MongoTemplate mongoTemplate() { + return template; + } +} diff --git a/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/config/MongoTestConfig.java b/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/config/MongoTestConfig.java new file mode 100644 index 0000000..3dd3277 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/config/MongoTestConfig.java @@ -0,0 +1,27 @@ +package cn.structure.infra.sample.mongodb.config; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; + +@Configuration +@SpringBootApplication(excludeName = { + "org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration", + "com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration", + "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration", + "org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration", + "org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchDataAutoConfiguration", + "org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchRepositoriesAutoConfiguration", + "org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration" +}) +@ComponentScan(basePackages = { + "cn.structure.infra.sample", + "cn.structure.infra.repository" +}, excludeFilters = { + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "cn.structure.infra.sample.infra.repository.mybatis.*"), + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "cn.structure.infra.sample.infra.repository.jpa.*"), + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "cn.structure.infra.sample.infra.repository.elasticsearch.*") +}) +public class MongoTestConfig { +} diff --git a/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/lowcode/MongoLowCodeRepositoryTest.java b/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/lowcode/MongoLowCodeRepositoryTest.java new file mode 100644 index 0000000..67c5fb0 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/lowcode/MongoLowCodeRepositoryTest.java @@ -0,0 +1,682 @@ +package cn.structure.infra.sample.mongodb.lowcode; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.lowcode.model.AutoFillType; +import cn.structure.infra.lowcode.model.FieldSchema; +import cn.structure.infra.lowcode.model.FieldType; +import cn.structure.infra.lowcode.model.RepositoryConfig; +import cn.structure.infra.lowcode.model.ResourceSchema; +import cn.structure.infra.lowcode.model.StorageType; +import cn.structure.infra.lowcode.repository.LowCodeRepository; +import cn.structure.infra.lowcode.router.LowCodeRepositoryRouter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.convert.MongoConverter; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * MongoDB 低代码仓储测试 - 通过 LowCodeRepository 接口测试 + *

+ * 验证 LowCodeRepositoryRouter 和 MongoDB 低代码存储的完整集成。 + * + * @author chuck + * @since 2026/6/29 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("MongoDB 低代码仓储测试 - LowCodeRepository 接口") +class MongoLowCodeRepositoryTest { + + @Mock + private MongoTemplate mongoTemplate; + + @Mock + private MongoConverter mongoConverter; + + private LowCodeRepository lowCodeRepository; + + private final Map> docCollections = new LinkedHashMap<>(); + private final AtomicLong docIdGenerator = new AtomicLong(1); + + private static final String RESOURCE_NAME = "article"; + private static final String COLLECTION_NAME = "t_lowcode_article"; + + @BeforeEach + void setUp() { + docCollections.clear(); + docIdGenerator.set(1); + setupMock(); + + ResourceSchema schema = ResourceSchema.builder() + .resourceName(RESOURCE_NAME) + .tableName(COLLECTION_NAME) + .build(); + + schema.addField(FieldSchema.builder() + .name("id") + .type(FieldType.LONG) + .primaryKey(true) + .build()); + + schema.addField(FieldSchema.builder() + .name("title") + .type(FieldType.STRING) + .length(200) + .nullable(false) + .build()); + + schema.addField(FieldSchema.builder() + .name("content") + .type(FieldType.TEXT) + .nullable(false) + .build()); + + schema.addField(FieldSchema.builder() + .name("author") + .type(FieldType.STRING) + .length(50) + .nullable(false) + .index(true) + .build()); + + schema.addField(FieldSchema.builder() + .name("category") + .type(FieldType.STRING) + .length(50) + .index(true) + .build()); + + schema.addField(FieldSchema.builder() + .name("viewCount") + .type(FieldType.LONG) + .defaultValue("0") + .build()); + + schema.addField(FieldSchema.builder() + .name("status") + .type(FieldType.INTEGER) + .defaultValue("1") + .build()); + + schema.addField(FieldSchema.builder() + .name("created_at") + .type(FieldType.DATETIME) + .autoFill(AutoFillType.CREATE) + .build()); + + schema.addField(FieldSchema.builder() + .name("updated_at") + .type(FieldType.DATETIME) + .autoFill(AutoFillType.CREATE_UPDATE) + .build()); + + RepositoryConfig config = new RepositoryConfig(); + config.setType(StorageType.MONGODB); + + cn.structure.infra.lowcode.repository.LowCodeRepoFactory factory = + new cn.structure.infra.mongodb.lowcode.MongoLowCodeRepoFactory(mongoTemplate); + + List factories = new ArrayList<>(); + factories.add(factory); + + LowCodeRepositoryRouter router = new LowCodeRepositoryRouter(factories); + router.registerResource(RESOURCE_NAME, schema, config); + lowCodeRepository = router; + } + + @SuppressWarnings("unchecked") + private void setupMock() { + when(mongoTemplate.getConverter()).thenReturn(mongoConverter); + + // Mock indexOps + org.springframework.data.mongodb.core.index.IndexOperations mockIndexOps = mock(org.springframework.data.mongodb.core.index.IndexOperations.class); + when(mongoTemplate.indexOps(anyString())).thenReturn(mockIndexOps); + when(mongoTemplate.indexOps(any(Class.class))).thenReturn(mockIndexOps); + + when(mongoTemplate.collectionExists(anyString())).thenReturn(true); + + doAnswer(invocation -> { + String collectionName = invocation.getArgument(0); + docCollections.remove(collectionName); + return null; + }).when(mongoTemplate).dropCollection(anyString()); + + when(mongoTemplate.findOne(any(Query.class), any(Class.class), anyString())).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + String collectionName = invocation.getArgument(2); + Map collection = docCollections.get(collectionName); + if (collection == null || collection.isEmpty()) { + return null; + } + List allDocs = new ArrayList<>(collection.values()); + List filtered = filterDocsByQuery(query, allDocs); + return filtered.isEmpty() ? null : filtered.get(0); + }); + + when(mongoTemplate.find(any(Query.class), any(Class.class), anyString())).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + String collectionName = invocation.getArgument(2); + Map collection = docCollections.get(collectionName); + if (collection == null || collection.isEmpty()) { + return new ArrayList<>(); + } + List allDocs = new ArrayList<>(collection.values()); + List filtered = filterDocsByQuery(query, allDocs); + + long skip = query.getSkip(); + int limit = query.getLimit(); + + List result = new ArrayList<>(); + int start = (int) skip; + int end = limit > 0 ? Math.min(start + limit, filtered.size()) : filtered.size(); + if (start < filtered.size()) { + result.addAll(filtered.subList(start, end)); + } + return result; + }); + + when(mongoTemplate.insert(any(org.bson.Document.class), anyString())).thenAnswer(invocation -> { + org.bson.Document doc = invocation.getArgument(0); + String collectionName = invocation.getArgument(1); + Map collection = docCollections.computeIfAbsent(collectionName, k -> new LinkedHashMap<>()); + + String idField = "id"; + if (!doc.containsKey(idField) || doc.get(idField) == null) { + doc.put(idField, docIdGenerator.getAndIncrement()); + } + Object id = doc.get(idField); + collection.put(id, doc); + return doc; + }); + + when(mongoTemplate.save(any(org.bson.Document.class), anyString())).thenAnswer(invocation -> { + org.bson.Document doc = invocation.getArgument(0); + String collectionName = invocation.getArgument(1); + Map collection = docCollections.computeIfAbsent(collectionName, k -> new LinkedHashMap<>()); + + String idField = "id"; + if (!doc.containsKey(idField) || doc.get(idField) == null) { + doc.put(idField, docIdGenerator.getAndIncrement()); + } + Object id = doc.get(idField); + collection.put(id, doc); + return doc; + }); + + when(mongoTemplate.updateFirst(any(Query.class), any(Update.class), anyString())).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + String collectionName = invocation.getArgument(2); + Map collection = docCollections.get(collectionName); + if (collection == null || collection.isEmpty()) { + return null; + } + List allDocs = new ArrayList<>(collection.values()); + List filtered = filterDocsByQuery(query, allDocs); + if (!filtered.isEmpty()) { + org.bson.Document firstDoc = filtered.get(0); + Update update = invocation.getArgument(1); + Map updates = extractUpdateValues(update); + firstDoc.putAll(updates); + } + return null; + }); + + doAnswer(invocation -> { + Query query = invocation.getArgument(0); + String collectionName = invocation.getArgument(1); + Map collection = docCollections.get(collectionName); + if (collection == null || collection.isEmpty()) { + return null; + } + List allDocs = new ArrayList<>(collection.values()); + List filtered = filterDocsByQuery(query, allDocs); + for (org.bson.Document doc : filtered) { + Object id = doc.get("id"); + if (id != null) { + collection.remove(id); + } + } + return null; + }).when(mongoTemplate).remove(any(Query.class), anyString()); + + when(mongoTemplate.count(any(Query.class), anyString())).thenAnswer(invocation -> { + Query query = invocation.getArgument(0); + String collectionName = invocation.getArgument(1); + Map collection = docCollections.get(collectionName); + if (collection == null || collection.isEmpty()) { + return 0L; + } + List allDocs = new ArrayList<>(collection.values()); + List filtered = filterDocsByQuery(query, allDocs); + return (long) filtered.size(); + }); + } + + private List filterDocsByQuery(Query query, List docs) { + try { + Field criteriaField = Query.class.getDeclaredField("criteria"); + criteriaField.setAccessible(true); + Object criteriaObj = criteriaField.get(query); + + if (criteriaObj instanceof Map) { + Map criteriaMap = (Map) criteriaObj; + List> conditions = new ArrayList<>(); + for (Map.Entry entry : criteriaMap.entrySet()) { + String fieldName = entry.getKey().toString(); + Object criteriaValue = entry.getValue(); + if (criteriaValue instanceof Criteria) { + Object value = extractCriteriaValue((Criteria) criteriaValue); + if (value != null) { + conditions.add(Map.entry(fieldName, value)); + } + } + } + if (!conditions.isEmpty()) { + return docs.stream() + .filter(doc -> matchesConditions(doc, conditions)) + .collect(java.util.stream.Collectors.toList()); + } + } + } catch (Exception ignored) { + } + return docs; + } + + private Object extractCriteriaValue(Criteria criteria) { + try { + Field isValueField = Criteria.class.getDeclaredField("isValue"); + isValueField.setAccessible(true); + Object value = isValueField.get(criteria); + if (value != null && !"java.lang.Object".equals(value.getClass().getName())) { + return value; + } + } catch (Exception ignored) { + } + return null; + } + + private boolean matchesConditions(org.bson.Document doc, List> conditions) { + for (Map.Entry condition : conditions) { + String fieldName = condition.getKey(); + Object expectedValue = condition.getValue(); + Object actualValue = doc.get(fieldName); + if (actualValue == null || !actualValue.equals(expectedValue)) { + return false; + } + } + return true; + } + + private Map extractUpdateValues(Update update) { + Map result = new LinkedHashMap<>(); + try { + Field modifierOpsField = Update.class.getDeclaredField("modifierOps"); + modifierOpsField.setAccessible(true); + Object modifierOps = modifierOpsField.get(update); + if (modifierOps instanceof Map) { + Map opsMap = (Map) modifierOps; + Object setDoc = opsMap.get("$set"); + if (setDoc instanceof org.bson.Document) { + result.putAll((org.bson.Document) setDoc); + } + } + } catch (Exception ignored) { + } + return result; + } + + private Map documentToMap(org.bson.Document doc) { + Map map = new LinkedHashMap<>(); + if (doc != null) { + for (String key : doc.keySet()) { + map.put(key, doc.get(key)); + } + } + return map; + } + + // ==================== 测试方法 ==================== + + @Test + @DisplayName("测试保存文章(新增)- LowCodeRepository") + void testSave_Insert() { + Map article = new LinkedHashMap<>(); + article.put("title", "Test Article Title"); + article.put("content", "This is test article content."); + article.put("author", "test_author"); + article.put("category", "tech"); + article.put("viewCount", 100L); + article.put("status", 1); + + Map result = lowCodeRepository.save(RESOURCE_NAME, article); + + assertNotNull(result); + assertNotNull(result.get("id"), "ID 应该自动生成"); + assertEquals("Test Article Title", result.get("title")); + assertEquals("test_author", result.get("author")); + assertNotNull(result.get("created_at"), "应该自动填充创建时间"); + assertNotNull(result.get("updated_at"), "应该自动填充更新时间"); + } + + @Test + @DisplayName("测试保存文章(更新)- LowCodeRepository") + void testSave_Update() { + Map article = new LinkedHashMap<>(); + article.put("title", "Original Title"); + article.put("content", "Original content"); + article.put("author", "original_author"); + article.put("category", "news"); + + Map saved = lowCodeRepository.save(RESOURCE_NAME, article); + Object id = saved.get("id"); + + Map updateData = new LinkedHashMap<>(); + updateData.put("id", id); + updateData.put("title", "Updated Title"); + updateData.put("viewCount", 200L); + + Map updated = lowCodeRepository.save(RESOURCE_NAME, updateData); + + assertEquals(id, updated.get("id")); + assertEquals("Updated Title", updated.get("title")); + assertEquals(200L, updated.get("viewCount")); + } + + @Test + @DisplayName("测试根据ID查询 - LowCodeRepository") + void testFindById() { + Map article = new LinkedHashMap<>(); + article.put("title", "Find By Id Test"); + article.put("content", "Content for find by id test"); + article.put("author", "test_author"); + + Map saved = lowCodeRepository.save(RESOURCE_NAME, article); + Object id = saved.get("id"); + + Map result = lowCodeRepository.findById(RESOURCE_NAME, id); + + assertNotNull(result); + assertEquals(id, result.get("id")); + assertEquals("Find By Id Test", result.get("title")); + } + + @Test + @DisplayName("测试条件查询单条 - LowCodeRepository") + void testQueryOne() { + Map article1 = new LinkedHashMap<>(); + article1.put("title", "Article One"); + article1.put("content", "Content one"); + article1.put("author", "author_1"); + article1.put("category", "tech"); + lowCodeRepository.save(RESOURCE_NAME, article1); + + Map article2 = new LinkedHashMap<>(); + article2.put("title", "Article Two"); + article2.put("content", "Content two"); + article2.put("author", "author_2"); + article2.put("category", "news"); + lowCodeRepository.save(RESOURCE_NAME, article2); + + Map params = new LinkedHashMap<>(); + params.put("category", "tech"); + + Map result = lowCodeRepository.queryOne(RESOURCE_NAME, params); + + assertNotNull(result); + assertEquals("Article One", result.get("title")); + assertEquals("tech", result.get("category")); + } + + @Test + @DisplayName("测试条件查询列表 - LowCodeRepository") + void testQueryList() { + for (int i = 1; i <= 5; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Tech Article " + i); + article.put("content", "Tech content " + i); + article.put("author", "tech_author"); + article.put("category", i <= 3 ? "tech" : "news"); + lowCodeRepository.save(RESOURCE_NAME, article); + } + + Map params = new LinkedHashMap<>(); + params.put("category", "tech"); + + List> results = lowCodeRepository.queryList(RESOURCE_NAME, params); + + assertNotNull(results); + assertEquals(3, results.size()); + } + + @Test + @DisplayName("测试分页查询 - LowCodeRepository") + void testQueryPage() { + for (int i = 1; i <= 25; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Page Article " + i); + article.put("content", "Page content " + i); + article.put("author", "page_author"); + article.put("category", "page_category"); + lowCodeRepository.save(RESOURCE_NAME, article); + } + + ReqPage reqPage = new ReqPage(); + reqPage.setPage(1); + reqPage.setSize(10); + ResPage> result = lowCodeRepository.queryPage(RESOURCE_NAME, reqPage); + + assertNotNull(result); + assertEquals(25L, result.getTotal()); + assertEquals(10, result.getRecords().size()); + } + + @Test + @DisplayName("测试删除文章 - LowCodeRepository") + void testRemoveById() { + Map article = new LinkedHashMap<>(); + article.put("title", "Delete Test Article"); + article.put("content", "Content to delete"); + article.put("author", "delete_author"); + + Map saved = lowCodeRepository.save(RESOURCE_NAME, article); + Object id = saved.get("id"); + + lowCodeRepository.removeById(RESOURCE_NAME, id); + + Map result = lowCodeRepository.findById(RESOURCE_NAME, id); + assertNull(result); + } + + @Test + @DisplayName("测试批量保存 - LowCodeRepository") + void testSaveBatch() { + List> articles = new ArrayList<>(); + for (int i = 1; i <= 5; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Batch Article " + i); + article.put("content", "Batch content " + i); + article.put("author", "batch_author"); + articles.add(article); + } + + List> results = lowCodeRepository.saveBatch(RESOURCE_NAME, articles); + + assertNotNull(results); + assertEquals(5, results.size()); + for (Map r : results) { + assertNotNull(r.get("id")); + } + } + + @Test + @DisplayName("测试批量删除 - LowCodeRepository") + void testRemoveBatchByIds() { + List ids = new ArrayList<>(); + for (int i = 1; i <= 3; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Batch Remove " + i); + article.put("content", "Content " + i); + article.put("author", "batch_remove_author"); + Map saved = lowCodeRepository.save(RESOURCE_NAME, article); + ids.add(saved.get("id")); + } + + lowCodeRepository.removeBatchByIds(RESOURCE_NAME, ids); + + for (Object id : ids) { + Map result = lowCodeRepository.findById(RESOURCE_NAME, id); + assertNull(result); + } + } + + @Test + @DisplayName("测试批量查询 - LowCodeRepository") + void testListByIds() { + List ids = new ArrayList<>(); + for (int i = 1; i <= 3; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "List By Ids " + i); + article.put("content", "Content " + i); + article.put("author", "list_author"); + Map saved = lowCodeRepository.save(RESOURCE_NAME, article); + ids.add(saved.get("id")); + } + + List> results = lowCodeRepository.listByIds(RESOURCE_NAME, ids); + + assertNotNull(results); + assertEquals(3, results.size()); + } + + @Test + @DisplayName("测试统计数量 - LowCodeRepository") + void testCount() { + for (int i = 1; i <= 5; i++) { + Map article = new LinkedHashMap<>(); + article.put("title", "Count Article " + i); + article.put("content", "Count content " + i); + article.put("author", "count_author"); + article.put("category", i <= 3 ? "tech" : "news"); + lowCodeRepository.save(RESOURCE_NAME, article); + } + + long count = lowCodeRepository.count(RESOURCE_NAME, null); + assertEquals(5, count); + + Map params = new LinkedHashMap<>(); + params.put("category", "tech"); + long conditionCount = lowCodeRepository.count(RESOURCE_NAME, params); + assertEquals(3, conditionCount); + } + + @Test + @DisplayName("测试判断存在 - LowCodeRepository") + void testExists() { + Map article = new LinkedHashMap<>(); + article.put("title", "Exists Test Article"); + article.put("content", "Content for exists test"); + article.put("author", "exists_author"); + article.put("category", "exists_category"); + lowCodeRepository.save(RESOURCE_NAME, article); + + Map params = new LinkedHashMap<>(); + params.put("author", "exists_author"); + boolean exists = lowCodeRepository.exists(RESOURCE_NAME, params); + assertTrue(exists); + + params.put("author", "not_exists_author"); + boolean notExists = lowCodeRepository.exists(RESOURCE_NAME, params); + assertFalse(notExists); + } + + @Test + @DisplayName("测试自动填充时间字段 - LowCodeRepository") + void testAutoFill() { + Map article = new LinkedHashMap<>(); + article.put("title", "AutoFill Test Article"); + article.put("content", "Content for auto fill test"); + article.put("author", "autofill_author"); + + Map result = lowCodeRepository.save(RESOURCE_NAME, article); + + assertNotNull(result.get("created_at")); + assertNotNull(result.get("updated_at")); + } + + @Test + @DisplayName("测试 queryById - LowCodeRepository") + void testQueryById() { + Map article = new LinkedHashMap<>(); + article.put("title", "QueryById Test"); + article.put("content", "Content for query by id test"); + article.put("author", "query_author"); + + Map saved = lowCodeRepository.save(RESOURCE_NAME, article); + Object id = saved.get("id"); + + Map result = lowCodeRepository.queryById(RESOURCE_NAME, id); + + assertNotNull(result); + assertEquals(id, result.get("id")); + assertEquals("QueryById Test", result.get("title")); + } + + @Test + @DisplayName("测试 queryByIdOptional - LowCodeRepository") + void testQueryByIdOptional() { + Map article = new LinkedHashMap<>(); + article.put("title", "Optional Test"); + article.put("content", "Content for optional test"); + article.put("author", "optional_author"); + + Map saved = lowCodeRepository.save(RESOURCE_NAME, article); + Object id = saved.get("id"); + + Optional> result = lowCodeRepository.queryByIdOptional(RESOURCE_NAME, id); + + assertTrue(result.isPresent()); + assertEquals(id, result.get().get("id")); + } + + @Test + @DisplayName("测试 queryOneOptional - LowCodeRepository") + void testQueryOneOptional() { + Map article = new LinkedHashMap<>(); + article.put("title", "QueryOneOptional Test"); + article.put("content", "Content"); + article.put("author", "qopt_author"); + article.put("category", "qopt_cat"); + lowCodeRepository.save(RESOURCE_NAME, article); + + Map params = new LinkedHashMap<>(); + params.put("category", "qopt_cat"); + + Optional> result = lowCodeRepository.queryOneOptional(RESOURCE_NAME, params); + + assertTrue(result.isPresent()); + assertEquals("QueryOneOptional Test", result.get().get("title")); + } +} diff --git a/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/lowcode/config/MongoLowCodeTestConfig.java b/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/lowcode/config/MongoLowCodeTestConfig.java new file mode 100644 index 0000000..2a3d52c --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mongodb/src/test/java/cn/structure/infra/sample/mongodb/lowcode/config/MongoLowCodeTestConfig.java @@ -0,0 +1,23 @@ +package cn.structure.infra.sample.mongodb.lowcode.config; + +import cn.structure.infra.lowcode.configuration.LowCodeAutoConfiguration; +import cn.structure.infra.mongodb.lowcode.MongoLowCodeAutoConfiguration; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * MongoDB 低代码测试配置 + *

+ * 导入低代码仓储的自动配置,配合 MongoTestConfig 和 MockMongoConfiguration 使用。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@Configuration +@Import({ + LowCodeAutoConfiguration.class, + MongoLowCodeAutoConfiguration.class +}) +public class MongoLowCodeTestConfig { +} diff --git a/structure-infra-sample/structure-infra-sample-mongodb/src/test/resources/application-mongo-test.yml b/structure-infra-sample/structure-infra-sample-mongodb/src/test/resources/application-mongo-test.yml new file mode 100644 index 0000000..c065518 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mongodb/src/test/resources/application-mongo-test.yml @@ -0,0 +1,16 @@ +structure: + infra: + type: MONGODB + +spring: + data: + mongodb: + uri: mongodb://localhost:27017/test + database: test + auto-index-creation: true + +logging: + level: + org.springframework.data.mongodb: DEBUG + cn.structure.infra.repository: DEBUG + cn.structure.infra.mongodb: INFO diff --git a/structure-infra-sample/structure-infra-sample-mybatis/pom.xml b/structure-infra-sample/structure-infra-sample-mybatis/pom.xml new file mode 100644 index 0000000..e70f342 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + cn.structured + structure-infra-sample + ${revision} + ../pom.xml + + + structure-infra-sample-mybatis + structure-infra-sample-mybatis + MyBatis Plus 示例模块 + jar + + + + cn.structured + structure-infra-sample-core + ${revision} + + + cn.structured + structure-infra-mybatis-plus-starter + + + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + org.projectlombok + lombok + provided + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-mybatis/src/main/java/cn/structure/infra/sample/InfraSampleApplication.java b/structure-infra-sample/structure-infra-sample-mybatis/src/main/java/cn/structure/infra/sample/InfraSampleApplication.java new file mode 100644 index 0000000..4db9a8f --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/src/main/java/cn/structure/infra/sample/InfraSampleApplication.java @@ -0,0 +1,19 @@ +package cn.structure.infra.sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 示例工程启动类 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@SpringBootApplication +public class InfraSampleApplication { + + public static void main(String[] args) { + SpringApplication.run(InfraSampleApplication.class, args); + } +} diff --git a/structure-infra-sample/structure-infra-sample-mybatis/src/main/java/cn/structure/infra/sample/infra/mapper/UserMapper.java b/structure-infra-sample/structure-infra-sample-mybatis/src/main/java/cn/structure/infra/sample/infra/mapper/UserMapper.java new file mode 100644 index 0000000..6d0c47b --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/src/main/java/cn/structure/infra/sample/infra/mapper/UserMapper.java @@ -0,0 +1,9 @@ +package cn.structure.infra.sample.infra.mapper; + +import cn.structure.infra.sample.infra.po.UserPO; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface UserMapper extends BaseMapper { +} diff --git a/structure-infra-sample/structure-infra-sample-mybatis/src/main/java/cn/structure/infra/sample/infra/repository/UserRepositoryImpl.java b/structure-infra-sample/structure-infra-sample-mybatis/src/main/java/cn/structure/infra/sample/infra/repository/UserRepositoryImpl.java new file mode 100644 index 0000000..a7d15c7 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/src/main/java/cn/structure/infra/sample/infra/repository/UserRepositoryImpl.java @@ -0,0 +1,12 @@ +package cn.structure.infra.sample.infra.repository; + +import cn.structure.infra.annotations.Repository; +import cn.structure.infra.repository.RepositoryType; +import cn.structure.infra.sample.domain.entity.UserEntity; +import cn.structure.infra.sample.infra.po.UserPO; +import org.springframework.stereotype.Component; + +@Repository(value = "用户仓储", type = RepositoryType.MYBATIS_PLUS, entity = UserEntity.class, po = UserPO.class) +@Component("userRepository") +public class UserRepositoryImpl extends AbstractUserRepositoryImpl { +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-mybatis/src/main/java/cn/structure/infra/sample/infra/repository/mybatis/UserMybatisPlusDelegate.java b/structure-infra-sample/structure-infra-sample-mybatis/src/main/java/cn/structure/infra/sample/infra/repository/mybatis/UserMybatisPlusDelegate.java new file mode 100644 index 0000000..2ae4074 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/src/main/java/cn/structure/infra/sample/infra/repository/mybatis/UserMybatisPlusDelegate.java @@ -0,0 +1,30 @@ +package cn.structure.infra.sample.infra.repository.mybatis; + +import cn.structure.infra.annotations.DelegateFor; +import cn.structure.infra.mybatis.plus.repository.MybatisPlusRepositoryDelegate; +import cn.structure.infra.repository.RepositoryType; +import cn.structure.infra.sample.infra.mapper.UserMapper; +import cn.structure.infra.sample.infra.po.UserPO; +import cn.structure.infra.sample.infra.repository.delegate.UserRepositoryDelegate; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@DelegateFor( + name = "userRepository", + type = RepositoryType.MYBATIS_PLUS, + po = UserPO.class, + description = "用户仓储 MyBatis Plus 实现", + priority = 10 +) +@AllArgsConstructor +public class UserMybatisPlusDelegate extends MybatisPlusRepositoryDelegate implements UserRepositoryDelegate { + + private final UserMapper userMapper; + + @Override + public UserPO finByName(String name) { + return userMapper.selectOne(Wrappers.lambdaQuery().eq(UserPO::getUsername, name)); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/InfraJpaTestApplication.java b/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/InfraJpaTestApplication.java new file mode 100644 index 0000000..cf93e5c --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/InfraJpaTestApplication.java @@ -0,0 +1,21 @@ +package cn.structure.infra.sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * JPA 测试启动类 + *

+ * 用于 JPA 场景测试,排除 MyBatis Plus 自动配置 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@SpringBootApplication(scanBasePackages = "cn.structure.infra.sample") +public class InfraJpaTestApplication { + + public static void main(String[] args) { + SpringApplication.run(InfraJpaTestApplication.class, args); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/config/MybatisOnlyConfig.java b/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/config/MybatisOnlyConfig.java new file mode 100644 index 0000000..b0909f0 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/config/MybatisOnlyConfig.java @@ -0,0 +1,30 @@ +package cn.structure.infra.sample.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; + +@Configuration +@ComponentScan(basePackages = { + "cn.structure.infra.sample", + "cn.structure.infra.repository" +}, excludeFilters = { + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "cn.structure.infra.sample.infra.repository.jpa.*"), + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "cn.structure.infra.sample.infra.repository.mongodb.*"), + @ComponentScan.Filter(type = FilterType.REGEX, pattern = "cn.structure.infra.sample.infra.repository.elasticsearch.*") +}) +@MapperScan("cn.structure.infra.sample.infra.mapper") +public class MybatisOnlyConfig { + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.H2)); + return interceptor; + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/config/TestConfig.java b/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/config/TestConfig.java new file mode 100644 index 0000000..2bdc198 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/config/TestConfig.java @@ -0,0 +1,20 @@ +package cn.structure.infra.sample.config; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@SpringBootApplication(excludeName = { + "org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration", + "org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration", + "org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration", + "org.springframework.boot.autoconfigure.data.mongo.MongoRepositoriesAutoConfiguration", + "org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchDataAutoConfiguration", + "org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchRepositoriesAutoConfiguration" +}) +@ComponentScan(basePackages = "cn.structure.infra.sample") +@MapperScan("cn.structure.infra.sample.infra.repository.mybatis.mapper") +public class TestConfig { +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/lowcode/LowCodeTestConfig.java b/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/lowcode/LowCodeTestConfig.java new file mode 100644 index 0000000..da1cded --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/lowcode/LowCodeTestConfig.java @@ -0,0 +1,24 @@ +package cn.structure.infra.sample.lowcode; + +import cn.structure.infra.sample.config.MybatisOnlyConfig; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * 低代码仓储测试配置 + *

+ * 基于 MybatisOnlyConfig,额外导入低代码仓储的自动配置。 + * 放在独立包下,避免被 MybatisOnlyConfig 的 ComponentScan 扫描导致 Bean 冲突。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@Configuration +@Import({ + MybatisOnlyConfig.class, + cn.structure.infra.lowcode.configuration.LowCodeAutoConfiguration.class, + cn.structure.infra.mybatis.plus.lowcode.configuration.MybatisPlusLowCodeAutoConfiguration.class +}) +public class LowCodeTestConfig { +} diff --git a/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/repository/LowCodeRepositoryTest.java b/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/repository/LowCodeRepositoryTest.java new file mode 100644 index 0000000..f815f0f --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/repository/LowCodeRepositoryTest.java @@ -0,0 +1,379 @@ +package cn.structure.infra.sample.repository; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.lowcode.repository.LowCodeRepository; +import cn.structure.infra.sample.lowcode.LowCodeTestConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 低代码仓储测试类 - MySQL/H2 实现 + *

+ * 测试 LowCodeRepository 的所有 CRUD 方法, + * 验证低代码仓储路由和 MySQL 存储实现的正确性。 + *

+ * 使用 H2 内存数据库进行测试,通过 YAML 配置定义低代码资源。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@SpringBootTest(classes = LowCodeTestConfig.class, properties = { + "structure.infra.lowcode.enabled=true", + "structure.infra.lowcode.resources.lc_user.schema.table-name=t_lowcode_user", + "structure.infra.lowcode.resources.lc_user.schema.fields.id.type=long", + "structure.infra.lowcode.resources.lc_user.schema.fields.id.primary-key=true", + "structure.infra.lowcode.resources.lc_user.schema.fields.id.auto-increment=true", + "structure.infra.lowcode.resources.lc_user.schema.fields.username.type=string", + "structure.infra.lowcode.resources.lc_user.schema.fields.username.length=64", + "structure.infra.lowcode.resources.lc_user.schema.fields.username.nullable=false", + "structure.infra.lowcode.resources.lc_user.schema.fields.username.index=true", + "structure.infra.lowcode.resources.lc_user.schema.fields.email.type=string", + "structure.infra.lowcode.resources.lc_user.schema.fields.email.length=128", + "structure.infra.lowcode.resources.lc_user.schema.fields.email.index=true", + "structure.infra.lowcode.resources.lc_user.schema.fields.age.type=int", + "structure.infra.lowcode.resources.lc_user.schema.fields.status.type=string", + "structure.infra.lowcode.resources.lc_user.schema.fields.status.length=16", + "structure.infra.lowcode.resources.lc_user.schema.fields.status.default-value=active", + "structure.infra.lowcode.resources.lc_user.schema.fields.created_at.type=datetime", + "structure.infra.lowcode.resources.lc_user.schema.fields.created_at.auto-fill=create", + "structure.infra.lowcode.resources.lc_user.schema.fields.updated_at.type=datetime", + "structure.infra.lowcode.resources.lc_user.schema.fields.updated_at.auto-fill=create_update", + "structure.infra.lowcode.resources.lc_user.repository.type=mysql" +}) +@DisplayName("低代码仓储 - MySQL 实现测试") +class LowCodeRepositoryTest { + + @Autowired + private LowCodeRepository lowCodeRepository; + + private static final String RESOURCE_NAME = "lc_user"; + + /** + * 构建用户测试数据 + */ + private Map createUser(String username, String email, Integer age) { + Map user = new HashMap<>(); + user.put("username", username); + user.put("email", email); + user.put("age", age); + return user; + } + + @Test + @DisplayName("测试保存用户 - 新增") + void testSave_Insert() { + Map user = createUser("zhangsan", "zhangsan@example.com", 25); + Map saved = lowCodeRepository.save(RESOURCE_NAME, user); + + assertNotNull(saved); + assertNotNull(saved.get("id")); + assertEquals("zhangsan", saved.get("username")); + assertEquals("zhangsan@example.com", saved.get("email")); + assertEquals(25, saved.get("age")); + assertNotNull(saved.get("created_at")); + assertNotNull(saved.get("updated_at")); + + System.out.println("保存用户成功: " + saved); + } + + @Test + @DisplayName("测试保存用户 - 更新") + void testSave_Update() { + Map user = createUser("updateUser", "update@test.com", 20); + Map saved = lowCodeRepository.save(RESOURCE_NAME, user); + assertNotNull(saved); + Object id = saved.get("id"); + + saved.put("email", "updated@test.com"); + saved.put("age", 30); + Map updated = lowCodeRepository.save(RESOURCE_NAME, saved); + + assertEquals(id, updated.get("id")); + assertEquals("updateUser", updated.get("username")); + assertEquals("updated@test.com", updated.get("email")); + assertEquals(30, updated.get("age")); + + System.out.println("更新用户成功: " + updated); + } + + @Test + @DisplayName("测试根据ID查询 - findById") + void testFindById() { + Map user = createUser("lisi", "lisi@example.com", 30); + Map saved = lowCodeRepository.save(RESOURCE_NAME, user); + + Map found = lowCodeRepository.findById(RESOURCE_NAME, saved.get("id")); + assertNotNull(found); + assertEquals(saved.get("id"), found.get("id")); + assertEquals("lisi", found.get("username")); + } + + @Test + @DisplayName("测试根据ID查询 - findById 不存在") + void testFindById_NotFound() { + Map found = lowCodeRepository.findById(RESOURCE_NAME, 99999L); + assertNull(found); + } + + @Test + @DisplayName("测试 queryById") + void testQueryById() { + Map user = createUser("wangwu", "wangwu@example.com", 28); + Map saved = lowCodeRepository.save(RESOURCE_NAME, user); + + Map found = lowCodeRepository.queryById(RESOURCE_NAME, saved.get("id")); + assertNotNull(found); + assertEquals("wangwu", found.get("username")); + } + + @Test + @DisplayName("测试 queryByIdOptional - 存在") + void testQueryByIdOptional_Exists() { + Map user = createUser("zhaoliu", "zhaoliu@example.com", 35); + Map saved = lowCodeRepository.save(RESOURCE_NAME, user); + + Optional> optional = lowCodeRepository.queryByIdOptional(RESOURCE_NAME, saved.get("id")); + assertTrue(optional.isPresent()); + assertEquals("zhaoliu", optional.get().get("username")); + } + + @Test + @DisplayName("测试 queryByIdOptional - 不存在") + void testQueryByIdOptional_NotExists() { + Optional> optional = lowCodeRepository.queryByIdOptional(RESOURCE_NAME, 99999L); + assertFalse(optional.isPresent()); + } + + @Test + @DisplayName("测试 queryOne - 条件查询单条") + void testQueryOne() { + lowCodeRepository.save(RESOURCE_NAME, createUser("qUser1", "q1@test.com", 20)); + lowCodeRepository.save(RESOURCE_NAME, createUser("qUser2", "q2@test.com", 25)); + + Map condition = new HashMap<>(); + condition.put("username", "qUser1"); + + Map found = lowCodeRepository.queryOne(RESOURCE_NAME, condition); + assertNotNull(found); + assertEquals("qUser1", found.get("username")); + assertEquals("q1@test.com", found.get("email")); + } + + @Test + @DisplayName("测试 queryOneOptional") + void testQueryOneOptional() { + lowCodeRepository.save(RESOURCE_NAME, createUser("optUser", "opt@test.com", 22)); + + Map condition = new HashMap<>(); + condition.put("username", "optUser"); + + Optional> optional = lowCodeRepository.queryOneOptional(RESOURCE_NAME, condition); + assertTrue(optional.isPresent()); + assertEquals("optUser", optional.get().get("username")); + } + + @Test + @DisplayName("测试 queryOneOptional - 不存在") + void testQueryOneOptional_NotExists() { + Map condition = new HashMap<>(); + condition.put("username", "nonexistent"); + + Optional> optional = lowCodeRepository.queryOneOptional(RESOURCE_NAME, condition); + assertFalse(optional.isPresent()); + } + + @Test + @DisplayName("测试 queryList - 查询全部") + void testQueryList_All() { + long beforeCount = lowCodeRepository.count(RESOURCE_NAME, null); + + lowCodeRepository.save(RESOURCE_NAME, createUser("listUser1", "list1@test.com", 20)); + lowCodeRepository.save(RESOURCE_NAME, createUser("listUser2", "list2@test.com", 25)); + lowCodeRepository.save(RESOURCE_NAME, createUser("listUser3", "list3@test.com", 30)); + + List> list = lowCodeRepository.queryList(RESOURCE_NAME, null); + assertEquals(beforeCount + 3, list.size()); + } + + @Test + @DisplayName("测试 queryList - 条件查询") + void testQueryList_ByCondition() { + lowCodeRepository.save(RESOURCE_NAME, createUser("ageUser1", "age1@test.com", 18)); + lowCodeRepository.save(RESOURCE_NAME, createUser("ageUser2", "age2@test.com", 25)); + lowCodeRepository.save(RESOURCE_NAME, createUser("ageUser3", "age3@test.com", 18)); + + Map condition = new HashMap<>(); + condition.put("age", 18); + + List> list = lowCodeRepository.queryList(RESOURCE_NAME, condition); + assertTrue(list.size() >= 2); + assertTrue(list.stream().allMatch(u -> Integer.valueOf(18).equals(u.get("age")))); + } + + @Test + @DisplayName("测试 queryList - 空列表") + void testQueryList_Empty() { + Map condition = new HashMap<>(); + condition.put("age", 999); + + List> list = lowCodeRepository.queryList(RESOURCE_NAME, condition); + assertNotNull(list); + assertTrue(list.isEmpty()); + } + + @Test + @DisplayName("测试 queryPage - 分页查询") + void testQueryPage() { + for (int i = 1; i <= 15; i++) { + lowCodeRepository.save(RESOURCE_NAME, createUser("pageUser" + i, "page" + i + "@test.com", 20 + i)); + } + + ReqPage reqPage = new ReqPage(); + reqPage.setPage(2); + reqPage.setSize(5); + + ResPage> page = lowCodeRepository.queryPage(RESOURCE_NAME, reqPage); + + assertNotNull(page); + assertEquals(2, page.getCurrent()); + assertEquals(5, page.getSize()); + assertTrue(page.getTotal() >= 15); + assertEquals(5, page.getRecords().size()); + assertTrue(page.getPages() >= 3); + + System.out.println("分页查询结果: 当前页=" + page.getCurrent() + + ", 总页数=" + page.getPages() + + ", 每页=" + page.getSize() + + ", 总数=" + page.getTotal() + + ", 记录数=" + page.getRecords().size()); + } + + @Test + @DisplayName("测试删除用户 - removeById") + void testRemoveById() { + Map user = createUser("delUser", "del@test.com", 20); + Map saved = lowCodeRepository.save(RESOURCE_NAME, user); + assertNotNull(lowCodeRepository.findById(RESOURCE_NAME, saved.get("id"))); + + lowCodeRepository.removeById(RESOURCE_NAME, saved.get("id")); + assertNull(lowCodeRepository.findById(RESOURCE_NAME, saved.get("id"))); + } + + @Test + @DisplayName("测试 saveBatch - 批量保存") + void testSaveBatch() { + List> users = List.of( + createUser("batchUser1", "batch1@test.com", 21), + createUser("batchUser2", "batch2@test.com", 22), + createUser("batchUser3", "batch3@test.com", 23) + ); + + List> savedList = lowCodeRepository.saveBatch(RESOURCE_NAME, users); + + assertEquals(3, savedList.size()); + for (Map saved : savedList) { + assertNotNull(saved.get("id")); + assertNotNull(saved.get("created_at")); + } + } + + @Test + @DisplayName("测试 removeBatchByIds - 批量删除") + void testRemoveBatchByIds() { + List> users = List.of( + createUser("batchDel1", "bd1@test.com", 21), + createUser("batchDel2", "bd2@test.com", 22), + createUser("batchDel3", "bd3@test.com", 23) + ); + List> savedList = lowCodeRepository.saveBatch(RESOURCE_NAME, users); + List ids = savedList.stream().map(u -> u.get("id")).toList(); + + assertEquals(3, lowCodeRepository.listByIds(RESOURCE_NAME, ids).size()); + + lowCodeRepository.removeBatchByIds(RESOURCE_NAME, ids); + + assertEquals(0, lowCodeRepository.listByIds(RESOURCE_NAME, ids).size()); + } + + @Test + @DisplayName("测试 listByIds - 批量查询") + void testListByIds() { + List> users = List.of( + createUser("listById1", "lid1@test.com", 21), + createUser("listById2", "lid2@test.com", 22), + createUser("listById3", "lid3@test.com", 23) + ); + List> savedList = lowCodeRepository.saveBatch(RESOURCE_NAME, users); + List ids = savedList.stream().map(u -> u.get("id")).toList(); + + List> result = lowCodeRepository.listByIds(RESOURCE_NAME, ids); + assertEquals(3, result.size()); + } + + @Test + @DisplayName("测试 count - 统计数量") + void testCount() { + long beforeCount = lowCodeRepository.count(RESOURCE_NAME, null); + + lowCodeRepository.save(RESOURCE_NAME, createUser("countUser", "count@test.com", 25)); + + long afterCount = lowCodeRepository.count(RESOURCE_NAME, null); + assertEquals(beforeCount + 1, afterCount); + + Map condition = new HashMap<>(); + condition.put("username", "countUser"); + long countByCondition = lowCodeRepository.count(RESOURCE_NAME, condition); + assertEquals(1, countByCondition); + } + + @Test + @DisplayName("测试 exists - 判断存在") + void testExists() { + lowCodeRepository.save(RESOURCE_NAME, createUser("existUser", "exist@test.com", 25)); + + Map condition = new HashMap<>(); + condition.put("username", "existUser"); + assertTrue(lowCodeRepository.exists(RESOURCE_NAME, condition)); + + Map notExistCondition = new HashMap<>(); + notExistCondition.put("username", "notExistUser"); + assertFalse(lowCodeRepository.exists(RESOURCE_NAME, notExistCondition)); + } + + @Test + @DisplayName("测试自动填充 - 创建时间和更新时间") + void testAutoFill() { + Map user = createUser("autoFillUser", "autofill@test.com", 25); + Map saved = lowCodeRepository.save(RESOURCE_NAME, user); + + assertNotNull(saved.get("created_at"), "创建时间应该被自动填充"); + assertNotNull(saved.get("updated_at"), "更新时间应该被自动填充"); + Object createdAt = saved.get("created_at"); + Object updatedAt = saved.get("updated_at"); + assertNotNull(createdAt); + assertNotNull(updatedAt); + assertEquals(String.valueOf(createdAt).substring(0, 19), String.valueOf(updatedAt).substring(0, 19), + "刚创建时创建时间和更新时间应该在秒级相同"); + } + + @Test + @DisplayName("测试默认值字段") + void testDefaultValue() { + Map user = createUser("defaultUser", "default@test.com", 25); + Map saved = lowCodeRepository.save(RESOURCE_NAME, user); + + assertEquals("active", saved.get("status"), "status 应该有默认值 active"); + } +} diff --git a/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/repository/UserRepositoryTest.java b/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/repository/UserRepositoryTest.java new file mode 100644 index 0000000..22e7c43 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/src/test/java/cn/structure/infra/sample/repository/UserRepositoryTest.java @@ -0,0 +1,227 @@ +package cn.structure.infra.sample.repository; + +import cn.structure.infra.sample.domain.entity.UserEntity; +import cn.structure.infra.sample.config.MybatisOnlyConfig; +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.sample.domain.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * UserRepository 测试类 - MyBatis Plus 实现 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@SpringBootTest(classes = MybatisOnlyConfig.class) +@DisplayName("MyBatis Plus 仓储测试") +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + private UserEntity createUser(String username, String email, Integer age) { + UserEntity user = new UserEntity(); + user.setUsername(username); + user.setEmail(email); + user.setAge(age); + user.setPassword("123456"); + user.setCreateTime(LocalDateTime.now()); + user.setUpdateTime(LocalDateTime.now()); + return user; + } + + @Test + @DisplayName("测试保存用户") + void testSave() { + UserEntity user = createUser("zhangsan", "zhangsan@example.com", 25); + UserEntity saved = userRepository.save(user); + + assertNotNull(saved); + assertNotNull(saved.getId()); + assertEquals("zhangsan", saved.getUsername()); + assertEquals("zhangsan@example.com", saved.getEmail()); + assertEquals(25, saved.getAge()); + + System.out.println("保存用户成功: " + saved); + } + + @Test + @DisplayName("测试根据ID查询") + void testFindById() { + UserEntity user = createUser("lisi", "lisi@example.com", 30); + UserEntity saved = userRepository.save(user); + + UserEntity found = userRepository.findById(saved.getId()); + assertNotNull(found); + assertEquals(saved.getId(), found.getId()); + assertEquals("lisi", found.getUsername()); + } + + @Test + @DisplayName("测试 queryById") + void testQueryById() { + UserEntity user = createUser("wangwu", "wangwu@example.com", 28); + UserEntity saved = userRepository.save(user); + + UserEntity found = userRepository.queryById(saved.getId()); + assertNotNull(found); + assertEquals("wangwu", found.getUsername()); + } + + @Test + @DisplayName("测试 queryByIdOptional - 存在") + void testQueryByIdOptional_Exists() { + UserEntity user = createUser("zhaoliu", "zhaoliu@example.com", 35); + UserEntity saved = userRepository.save(user); + + Optional optional = userRepository.queryByIdOptional(saved.getId()); + assertTrue(optional.isPresent()); + assertEquals("zhaoliu", optional.get().getUsername()); + } + + @Test + @DisplayName("测试 queryByIdOptional - 不存在") + void testQueryByIdOptional_NotExists() { + Optional optional = userRepository.queryByIdOptional(9999L); + assertFalse(optional.isPresent()); + } + + @Test + @DisplayName("测试 queryOne - 条件查询单条") + void testQueryOne() { + userRepository.save(createUser("user1", "user1@test.com", 20)); + userRepository.save(createUser("user2", "user2@test.com", 25)); + + UserEntity condition = new UserEntity(); + condition.setUsername("user1"); + + UserEntity found = userRepository.queryOne(condition); + assertNotNull(found); + assertEquals("user1", found.getUsername()); + assertEquals("user1@test.com", found.getEmail()); + } + + @Test + @DisplayName("测试 queryOneOptional") + void testQueryOneOptional() { + userRepository.save(createUser("optUser", "opt@test.com", 22)); + + UserEntity condition = new UserEntity(); + condition.setUsername("optUser"); + + Optional optional = userRepository.queryOneOptional(condition); + assertTrue(optional.isPresent()); + assertEquals("optUser", optional.get().getUsername()); + } + + @Test + @DisplayName("测试 queryOneOptional - 不存在") + void testQueryOneOptional_NotExists() { + UserEntity condition = new UserEntity(); + condition.setUsername("nonexistent"); + + Optional optional = userRepository.queryOneOptional(condition); + assertFalse(optional.isPresent()); + } + + @Test + @DisplayName("测试 queryList - 查询全部") + void testQueryList_All() { + int beforeCount = userRepository.queryList(null).size(); + + userRepository.save(createUser("listUser1", "list1@test.com", 20)); + userRepository.save(createUser("listUser2", "list2@test.com", 25)); + userRepository.save(createUser("listUser3", "list3@test.com", 30)); + + List list = userRepository.queryList(null); + assertEquals(beforeCount + 3, list.size()); + } + + @Test + @DisplayName("测试 queryList - 条件查询") + void testQueryList_ByCondition() { + userRepository.save(createUser("ageUser1", "age1@test.com", 18)); + userRepository.save(createUser("ageUser2", "age2@test.com", 25)); + userRepository.save(createUser("ageUser3", "age3@test.com", 18)); + + UserEntity condition = new UserEntity(); + condition.setAge(18); + + List list = userRepository.queryList(condition); + assertTrue(list.size() >= 2); + assertTrue(list.stream().allMatch(u -> u.getAge() == 18)); + } + + @Test + @DisplayName("测试 queryList - 列表返回空集合") + void testQueryList_Empty() { + UserEntity condition = new UserEntity(); + condition.setAge(999); + + List list = userRepository.queryList(condition); + assertNotNull(list); + assertTrue(list.isEmpty()); + } + + @Test + @DisplayName("测试 queryPage - 分页查询") + void testQueryPage() { + for (int i = 1; i <= 15; i++) { + userRepository.save(createUser("pageUser" + i, "page" + i + "@test.com", 20 + i)); + } + + ReqPage reqPage = new ReqPage(); + reqPage.setPage(2); + reqPage.setSize(5); + + ResPage page = userRepository.queryPage(reqPage); + + assertNotNull(page); + assertEquals(2, page.getCurrent()); + assertEquals(5, page.getSize()); + assertTrue(page.getTotal() >= 15); + assertEquals(5, page.getRecords().size()); + + System.out.println("分页查询结果: 当前页=" + page.getCurrent() + + ", 总页数=" + page.getPages() + + ", 每页=" + page.getSize() + + ", 总数=" + page.getTotal() + + ", 记录数=" + page.getRecords().size()); + } + + @Test + @DisplayName("测试删除用户") + void testRemoveById() { + UserEntity user = createUser("delUser", "del@test.com", 20); + UserEntity saved = userRepository.save(user); + assertNotNull(userRepository.findById(saved.getId())); + + userRepository.removeById(saved.getId()); + assertNull(userRepository.findById(saved.getId())); + } + + @Test + @DisplayName("测试 Entity <-> PO 转换") + void testEntityPoConversion() { + UserEntity user = createUser("convertUser", "convert@test.com", 28); + UserEntity saved = userRepository.save(user); + + UserEntity found = userRepository.findById(saved.getId()); + assertNotNull(found); + assertEquals(saved.getId(), found.getId()); + assertEquals(saved.getUsername(), found.getUsername()); + assertEquals(saved.getEmail(), found.getEmail()); + assertEquals(saved.getAge(), found.getAge()); + } +} \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-mybatis/src/test/resources/application.yml b/structure-infra-sample/structure-infra-sample-mybatis/src/test/resources/application.yml new file mode 100644 index 0000000..41e487f --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/src/test/resources/application.yml @@ -0,0 +1,29 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + sql: + init: + mode: always + schema-locations: classpath:schema.sql + h2: + console: + enabled: true + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration + - org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration + - org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration + - org.springframework.boot.autoconfigure.data.mongo.MongoRepositoriesAutoConfiguration + - org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchDataAutoConfiguration + - org.springframework.boot.autoconfigure.data.elasticsearch.ElasticsearchRepositoriesAutoConfiguration + +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + db-config: + id-type: auto \ No newline at end of file diff --git a/structure-infra-sample/structure-infra-sample-mybatis/src/test/resources/schema.sql b/structure-infra-sample/structure-infra-sample-mybatis/src/test/resources/schema.sql new file mode 100644 index 0000000..3b13648 --- /dev/null +++ b/structure-infra-sample/structure-infra-sample-mybatis/src/test/resources/schema.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS t_user ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(255) NOT NULL, + password VARCHAR(255), + email VARCHAR(255), + age INT, + create_time TIMESTAMP, + update_time TIMESTAMP +); diff --git a/structure-infra-starter/pom.xml b/structure-infra-starter/pom.xml new file mode 100644 index 0000000..bf8264e --- /dev/null +++ b/structure-infra-starter/pom.xml @@ -0,0 +1,37 @@ + + + 4.0.0 + + cn.structured + structure-pro-infra + ${revision} + ../pom.xml + + + structure-pro-starter + structure-infra-starter + mstructure-pro-starter + jar + + + + cn.structured + structure-common + + + cn.structured + structure-datascope-starter + + + cn.structured + structure-datascope-message + + + cn.structured + structure-datascope-cache + + + + \ No newline at end of file diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/annotations/DelegateFor.java b/structure-infra-starter/src/main/java/cn/structure/infra/annotations/DelegateFor.java new file mode 100644 index 0000000..a6843e4 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/annotations/DelegateFor.java @@ -0,0 +1,83 @@ +package cn.structure.infra.annotations; + +import cn.structure.infra.repository.DelegateType; +import cn.structure.infra.repository.RepositoryType; + +import java.lang.annotation.*; + +/** + * 仓储委托实现标记注解 + *

+ * 标注在具体的 RepositoryDelegate 实现类上 + *

+ * 示例: + *

+ * @DelegateFor(name = "userRepository", po = UserPO.class, delegateType = DelegateType.BASE)
+ * public class UserMybatisPlusDelegate extends MybatisPlusRepositoryDelegate<UserPO, Long> {
+ *     // 实现
+ * }
+ * 
+ *

+ * CQRS 模式下可以指定读代理: + *

+ * @DelegateFor(name = "userRepository", po = UserPO.class, delegateType = DelegateType.READ)
+ * public class UserReadDelegate extends ElasticsearchRepositoryDelegate<UserPO, Long> {
+ *     // 读操作实现
+ * }
+ * 
+ * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@Inherited +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface DelegateFor { + + /** + * 仓储名称,对应 RepositoryFacade 的 Bean 名称 + * + * @return 仓储名称 + */ + String name() default ""; + + /** + * 存储类型 + * + * @return 仓储类型 + */ + RepositoryType type() default RepositoryType.AUTO; + + /** + * 持久化对象类型 + * + * @return PO 类 + */ + Class po() default Object.class; + + /** + * 描述 + * + * @return 描述信息 + */ + String description() default ""; + + /** + * 优先级,多个同类型 Delegate 时使用优先级高的 + * + * @return 优先级,数字越大优先级越高 + */ + int priority() default 0; + + /** + * 委托类型 + *

+ * - BASE: 基础代理,承担写操作和默认读操作 + * - READ: 读代理,专门承担读操作(CQRS 模式下使用) + * + * @return 委托类型 + */ + DelegateType delegateType() default DelegateType.BASE; +} \ No newline at end of file diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/annotations/Repository.java b/structure-infra-starter/src/main/java/cn/structure/infra/annotations/Repository.java new file mode 100644 index 0000000..90bfee5 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/annotations/Repository.java @@ -0,0 +1,104 @@ +package cn.structure.infra.annotations; + +import cn.structure.infra.repository.RepositoryType; + +import java.lang.annotation.*; +import java.util.concurrent.TimeUnit; + +@Inherited +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface Repository { + + /** + * 仓储名称 + * + * @return + */ + String value() default ""; + + /** + * 仓储类型 默认自动 + * + * @return + */ + RepositoryType type() default RepositoryType.AUTO; + + + /** + * 实体类 + * + * @return + */ + Class entity() default Object.class; + + /** + * PO持久化对象类型 + *

+ * 用于 RepositoryFacade 中的 Entity <-> PO 转换 + * + * @return + */ + Class po() default Object.class; + + /** + * 主键类型 + *

+ * 默认 Long,如果需要指定其他类型可配置 + * + * @return + */ + Class id() default Long.class; + + /** + * 仓储描述 + * + * @return + */ + String description() default ""; + + + /** + * 是否缓存 + * + * @return + */ + boolean cache() default false; + + /** + * 缓存时间 + * + * @return + */ + long cacheTime() default 60L; + + /** + * 缓存时间单位 + * + * @return + */ + TimeUnit cacheTimeUnit() default TimeUnit.SECONDS; + + /** + * 是否启用 CQRS 读写分离 + *

+ * 启用后,读操作使用 readDelegate,写操作使用 baseDelegate + *

+ * 必须与 readDelegateClass 配合使用,两者同时成立时才启用读代理 + * + * @return true 启用 CQRS + */ + boolean cqrs() default false; + + /** + * 读代理类 + *

+ * 指定读操作使用的代理类,用于 CQRS 读写分离 + *

+ * 必须与 cqrs=true 配合使用,两者同时成立时才启用读代理 + * + * @return 读代理类 + */ + Class readDelegateClass() default Object.class; + +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoEventConfiguration.java b/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoEventConfiguration.java new file mode 100644 index 0000000..88b646a --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoEventConfiguration.java @@ -0,0 +1,24 @@ +package cn.structure.infra.configuration; + +import cn.structure.infra.event.DefaultEventManagerImpl; +import cn.structure.infra.event.EventManager; +import cn.structure.infra.properties.InfraProperties; +import cn.structured.datascope.message.wrapper.DataScopeStreamBridge; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(InfraProperties.class) +public class AutoEventConfiguration { + + @Bean + @ConditionalOnBean(EventManager.class) + public EventManager eventManager(ApplicationEventPublisher applicationEventPublisher, + DataScopeStreamBridge streamBridge, + InfraProperties infraProperties) { + return new DefaultEventManagerImpl(applicationEventPublisher, streamBridge, infraProperties); + } +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoRepositoryConfiguration.java b/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoRepositoryConfiguration.java new file mode 100644 index 0000000..13d2c6c --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/configuration/AutoRepositoryConfiguration.java @@ -0,0 +1,52 @@ +package cn.structure.infra.configuration; + +import cn.structure.infra.repository.RepositoryBeanPostProcessor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 仓储自动装配配置类 + *

+ * 负责注册仓储框架的核心组件,实现 RepositoryDelegate 到 RepositoryFacade 的自动注入机制。 + *

+ * 核心功能: + * 1. 注册 {@link RepositoryBeanPostProcessor},在 Spring 容器初始化过程中扫描所有 Delegate 和 Facade + * 2. 自动将匹配的 RepositoryDelegate 注入到对应的 RepositoryFacade 中 + * 3. 支持 CQRS 模式,区分 BASE(写)和 READ(读)两种 Delegate + * 4. 当找不到匹配的 Delegate 时,自动尝试通过 DelegateFactory 创建,或使用默认的内存实现 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@Configuration +public class AutoRepositoryConfiguration { + + /** + * 注册 Repository Bean 后处理器 + *

+ * 这是仓储框架的核心组件,负责在 Spring 容器启动过程中完成以下工作: + *

+ * 1. **收集阶段(postProcessBeforeInitialization)**: + * - 扫描所有带有 {@link cn.structure.infra.annotations.DelegateFor} 注解的 Bean + * - 收集 RepositoryDelegate 和 IQueryDelegate 的元信息 + * - 按 priority 优先级排序 + *

+ * 2. **识别阶段(postProcessAfterInitialization)**: + * - 识别所有 RepositoryFacade 的实例 + * - 记录 Facade 的 Bean 名称和引用 + *

+ * 3. **注入阶段(ContextRefreshedEvent)**: + * - 根据泛型参数和注解配置,为每个 Facade 查找匹配的 Delegate + * - 优先使用用户自定义的 Delegate(通过 @DelegateFor 声明) + * - 若无匹配,自动通过 {@link cn.structure.infra.repository.RepositoryDelegateFactory} 创建 + * - 最后回退到默认的 {@link cn.structure.infra.repository.InMemoryRepositoryDelegate} + * - 支持 CQRS 模式,分别注入 BASE 和 READ Delegate + * + * @return RepositoryBeanPostProcessor 实例 + */ + @Bean + public static RepositoryBeanPostProcessor repositoryBeanPostProcessor() { + return new RepositoryBeanPostProcessor(); + } +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/event/DefaultEventManagerImpl.java b/structure-infra-starter/src/main/java/cn/structure/infra/event/DefaultEventManagerImpl.java new file mode 100644 index 0000000..ba35d1a --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/event/DefaultEventManagerImpl.java @@ -0,0 +1,44 @@ +package cn.structure.infra.event; + +import cn.structure.infra.properties.InfraProperties; +import cn.structured.datascope.message.wrapper.DataScopeStreamBridge; +import lombok.AllArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; + +/** + *

+ * 事件管理器 + *

+ * + * @author chuck + * @version 1.0.1 + * @since 2021/6/21 16:05 + */ +@AllArgsConstructor +public class DefaultEventManagerImpl implements EventManager { + + private final ApplicationEventPublisher eventPublisher; + + private final DataScopeStreamBridge streamBridge; + + private final InfraProperties infraProperties; + + @Override + public void publish(Event event) { + if (event.getEventChannel().equals(EventChannel.DEFAULT)) { + if (infraProperties.getDefaultEventChannel() == EventChannel.SPRING_EVENT) { + eventPublisher.publishEvent(event); + } + if (infraProperties.getDefaultEventChannel() == EventChannel.MESSAGE_EVENT) { + streamBridge.send(event.getEventId(), event); + } + } else { + if (event.getEventChannel() == EventChannel.SPRING_EVENT) { + eventPublisher.publishEvent(event); + } + if (event.getEventChannel() == EventChannel.MESSAGE_EVENT) { + streamBridge.send(event.getEventId(), event); + } + } + } +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/event/Event.java b/structure-infra-starter/src/main/java/cn/structure/infra/event/Event.java new file mode 100644 index 0000000..2c5aafd --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/event/Event.java @@ -0,0 +1,30 @@ +package cn.structure.infra.event; + +/** + *

+ * 事件接口 + *

+ * + * @author chuck + * @version 1.0.1 + * @since 2021/6/1 17:01 + */ +public interface Event { + + /** + * 获取事件ID 事件ID + * + * @return 事件ID + */ + String getEventId(); + + /** + * 获取事件渠道类型 + * + * @return 事件类型 + */ + default EventChannel getEventChannel() { + return EventChannel.DEFAULT; + } + +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/event/EventChannel.java b/structure-infra-starter/src/main/java/cn/structure/infra/event/EventChannel.java new file mode 100644 index 0000000..7017f36 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/event/EventChannel.java @@ -0,0 +1,11 @@ +package cn.structure.infra.event; + +import lombok.Getter; + +@Getter +public enum EventChannel { + DEFAULT, + SPRING_EVENT, + MESSAGE_EVENT, + ; +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/event/EventManager.java b/structure-infra-starter/src/main/java/cn/structure/infra/event/EventManager.java new file mode 100644 index 0000000..06a83bb --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/event/EventManager.java @@ -0,0 +1,20 @@ +package cn.structure.infra.event; + +/** + *

+ * 事件管理器 + *

+ * + * @author chuck + * @version 1.0.1 + * @since 2021/6/21 16:05 + */ +public interface EventManager { + + /** + * 发布事件 + * @param event 事件 + */ + void publish(Event event); + +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/configuration/LowCodeAutoConfiguration.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/configuration/LowCodeAutoConfiguration.java new file mode 100644 index 0000000..0be21d3 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/configuration/LowCodeAutoConfiguration.java @@ -0,0 +1,76 @@ +package cn.structure.infra.lowcode.configuration; + +import cn.structure.infra.lowcode.properties.LowCodeProperties; +import cn.structure.infra.lowcode.registry.ResourceSchemaBuilder; +import cn.structure.infra.lowcode.repository.LowCodeRepoFactory; +import cn.structure.infra.lowcode.router.LowCodeRepositoryRouter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +import java.util.List; + +/** + * 低代码仓储自动配置类 + *

+ * 负责低代码仓储体系的自动装配,核心功能: + *

    + *
  • 注册 {@link LowCodeRepositoryRouter} 作为低代码仓储的统一入口
  • + *
  • 加载 YAML 配置中的资源定义,自动注册到路由引擎
  • + *
  • 收集所有 {@link LowCodeRepoFactory} 实现,供路由引擎使用
  • + *
+ *

+ * 可通过 {@code structure.infra.lowcode.enabled=false} 关闭低代码功能。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@Slf4j +@AutoConfiguration +@EnableConfigurationProperties(LowCodeProperties.class) +@ConditionalOnProperty(prefix = "structure.infra.lowcode", name = "enabled", havingValue = "true", matchIfMissing = true) +public class LowCodeAutoConfiguration { + + /** + * 注册低代码仓储路由引擎 + *

+ * 路由引擎是低代码仓储体系的核心调度器,负责: + *

    + *
  1. 接收所有 LowCodeRepoFactory 实现,建立存储类型到工厂的映射
  2. + *
  3. 从配置中加载资源定义,自动创建对应的存储实例
  4. + *
  5. 对外提供统一的 LowCodeRepository 接口
  6. + *
+ * + * @param factories 所有可用的仓储工厂 + * @param properties 低代码配置属性 + * @return 低代码仓储路由引擎实例 + */ + @Bean + public LowCodeRepositoryRouter lowCodeRepositoryRouter(List factories, + LowCodeProperties properties) { + LowCodeRepositoryRouter router = new LowCodeRepositoryRouter(factories); + + if (properties.getResources() != null && !properties.getResources().isEmpty()) { + for (var entry : properties.getResources().entrySet()) { + String resourceName = entry.getKey(); + LowCodeProperties.ResourceProperties resourceProps = entry.getValue(); + + if (resourceProps.getSchema() == null || resourceProps.getRepository() == null) { + log.warn("Resource {} has no schema or repository config, skipped", resourceName); + continue; + } + + var schema = ResourceSchemaBuilder.buildSchema(resourceName, resourceProps.getSchema()); + var repoConfig = ResourceSchemaBuilder.buildRepositoryConfig(resourceProps.getRepository()); + + router.registerResource(resourceName, schema, repoConfig); + log.info("LowCode resource registered: {}", resourceName); + } + } + + return router; + } +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/AutoFillType.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/AutoFillType.java new file mode 100644 index 0000000..9fcba54 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/AutoFillType.java @@ -0,0 +1,33 @@ +package cn.structure.infra.lowcode.model; + +/** + * 自动填充类型枚举 + *

+ * 定义字段在数据写入时的自动填充策略,减少重复的字段赋值代码。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +public enum AutoFillType { + + /** + * 不自动填充 + */ + NONE, + + /** + * 仅创建时填充 + */ + CREATE, + + /** + * 仅更新时填充 + */ + UPDATE, + + /** + * 创建和更新时都填充 + */ + CREATE_UPDATE +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/CacheConfig.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/CacheConfig.java new file mode 100644 index 0000000..6f40994 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/CacheConfig.java @@ -0,0 +1,33 @@ +package cn.structure.infra.lowcode.model; + +import lombok.Data; + +import java.util.concurrent.TimeUnit; + +/** + * 缓存配置 + *

+ * 定义低代码仓储的缓存策略,启用后查询数据会自动缓存以提升性能。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@Data +public class CacheConfig { + + /** + * 是否启用缓存 + */ + private boolean enabled; + + /** + * 缓存过期时间 + */ + private long ttl = 300; + + /** + * 时间单位 + */ + private TimeUnit timeUnit = TimeUnit.SECONDS; +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/CqrsConfig.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/CqrsConfig.java new file mode 100644 index 0000000..2335521 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/CqrsConfig.java @@ -0,0 +1,32 @@ +package cn.structure.infra.lowcode.model; + +import lombok.Data; + +/** + * CQRS 配置 + *

+ * 定义读写分离的配置,启用后读操作优先使用读存储,写操作使用基础存储, + * 读存储异常时自动回退到基础存储。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@Data +public class CqrsConfig { + + /** + * 是否启用 CQRS 读写分离 + */ + private boolean enabled; + + /** + * 读存储类型 + */ + private StorageType readType; + + /** + * 读数据源名称 + */ + private String readDatasource; +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/FieldSchema.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/FieldSchema.java new file mode 100644 index 0000000..03f327e --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/FieldSchema.java @@ -0,0 +1,98 @@ +package cn.structure.infra.lowcode.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 字段 Schema 定义 + *

+ * 描述低代码资源中单个字段的元数据信息,包括字段名称、类型、约束、 + * 默认值、自动填充策略等。框架根据这些信息自动生成 DDL 和操作 SQL。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FieldSchema { + + /** + * 字段名称(Java 侧使用的名称) + */ + private String name; + + /** + * 数据库列名(为空时默认与 name 相同) + */ + private String columnName; + + /** + * 字段类型 + */ + private FieldType type; + + /** + * 字段长度(字符串类型使用) + */ + @Builder.Default + private int length = 255; + + /** + * 精度(十进制类型使用,总位数) + */ + @Builder.Default + private int precision = 10; + + /** + * 小数位数(十进制类型使用) + */ + @Builder.Default + private int scale = 2; + + /** + * 是否为主键 + */ + private boolean primaryKey; + + /** + * 是否自增(仅数值类型主键有效) + */ + private boolean autoIncrement; + + /** + * 是否允许为空 + */ + @Builder.Default + private boolean nullable = true; + + /** + * 是否唯一约束 + */ + private boolean unique; + + /** + * 是否创建索引 + */ + private boolean index; + + /** + * 默认值 + */ + private String defaultValue; + + /** + * 自动填充策略 + */ + @Builder.Default + private AutoFillType autoFill = AutoFillType.NONE; + + /** + * 字段描述 + */ + private String description; +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/FieldType.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/FieldType.java new file mode 100644 index 0000000..faae874 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/FieldType.java @@ -0,0 +1,64 @@ +package cn.structure.infra.lowcode.model; + +/** + * 字段类型枚举 + *

+ * 定义低代码资源 schema 中支持的字段类型,框架会根据类型 + * 自动映射到对应存储引擎的原生数据类型。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +public enum FieldType { + + /** + * 字符串类型 + */ + STRING, + + /** + * 长整型(64位) + */ + LONG, + + /** + * 整型(32位) + */ + INTEGER, + + /** + * 布尔型 + */ + BOOLEAN, + + /** + * 高精度十进制 + */ + DECIMAL, + + /** + * 日期时间 + */ + DATETIME, + + /** + * 日期 + */ + DATE, + + /** + * MongoDB ObjectId + */ + OBJECT_ID, + + /** + * 长文本 + */ + TEXT, + + /** + * JSON 类型 + */ + JSON +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/RepositoryConfig.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/RepositoryConfig.java new file mode 100644 index 0000000..cd2360a --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/RepositoryConfig.java @@ -0,0 +1,55 @@ +package cn.structure.infra.lowcode.model; + +import lombok.Data; + +/** + * 仓储配置 + *

+ * 定义低代码资源的存储配置,包括存储类型、数据源、CQRS 读写分离、缓存等。 + * 路由引擎根据此配置选择并创建对应的仓储实现。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@Data +public class RepositoryConfig { + + /** + * 存储类型(默认 MySQL) + */ + private StorageType type = StorageType.MYSQL; + + /** + * 数据源名称 + */ + private String datasource; + + /** + * CQRS 读写分离配置 + */ + private CqrsConfig cqrs; + + /** + * 缓存配置 + */ + private CacheConfig cache; + + /** + * 是否启用了 CQRS 读写分离 + * + * @return true 表示启用 + */ + public boolean isCqrsEnabled() { + return cqrs != null && cqrs.isEnabled(); + } + + /** + * 是否启用了缓存 + * + * @return true 表示启用 + */ + public boolean isCacheEnabled() { + return cache != null && cache.isEnabled(); + } +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/ResourceSchema.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/ResourceSchema.java new file mode 100644 index 0000000..7fb68da --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/ResourceSchema.java @@ -0,0 +1,86 @@ +package cn.structure.infra.lowcode.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 资源 Schema 定义 + *

+ * 描述低代码资源的完整元数据,包括资源名称、表名、主键信息和所有字段定义。 + * 作为低代码仓储的核心元数据模型,框架根据 Schema 自动生成 DDL 和 DML 语句。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ResourceSchema { + + /** + * 资源名称(唯一标识,用于路由查找) + */ + private String resourceName; + + /** + * 表名/集合名/索引名(对应存储引擎中的数据容器名称) + */ + private String tableName; + + /** + * 主键字段名 + */ + private String idFieldName; + + /** + * 主键类型 + */ + private FieldType idType; + + /** + * 字段定义集合(保持插入顺序) + */ + @Builder.Default + private Map fields = new LinkedHashMap<>(); + + /** + * 获取主键字段定义 + * + * @return 主键字段 schema,未设置时返回 null + */ + public FieldSchema getIdField() { + return fields.get(idFieldName); + } + + /** + * 获取指定名称的字段定义 + * + * @param fieldName 字段名称 + * @return 字段 schema,不存在时返回 null + */ + public FieldSchema getField(String fieldName) { + return fields.get(fieldName); + } + + /** + * 添加字段定义 + *

+ * 如果字段是主键,会自动设置 idFieldName 和 idType。 + * + * @param field 字段定义 + */ + public void addField(FieldSchema field) { + fields.put(field.getName(), field); + if (field.isPrimaryKey()) { + idFieldName = field.getName(); + idType = field.getType(); + } + } +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/StorageType.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/StorageType.java new file mode 100644 index 0000000..eb8bf85 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/model/StorageType.java @@ -0,0 +1,38 @@ +package cn.structure.infra.lowcode.model; + +/** + * 存储类型枚举 + *

+ * 定义低代码仓储支持的存储引擎类型,用于路由到对应的仓储实现。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +public enum StorageType { + + /** + * MySQL 关系型数据库 + */ + MYSQL, + + /** + * MongoDB 文档数据库 + */ + MONGODB, + + /** + * Elasticsearch 搜索引擎 + */ + ELASTICSEARCH, + + /** + * Redis 缓存数据库 + */ + REDIS, + + /** + * 内存存储(用于测试或临时数据) + */ + IN_MEMORY +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/properties/LowCodeProperties.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/properties/LowCodeProperties.java new file mode 100644 index 0000000..08bacf2 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/properties/LowCodeProperties.java @@ -0,0 +1,235 @@ +package cn.structure.infra.lowcode.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 低代码仓储配置属性 + *

+ * 对应 YAML 配置前缀:{@code structure.infra.lowcode} + *

+ * 支持通过配置文件定义低代码资源,包括资源的 schema 结构和仓储配置。 + * 框架启动时自动加载这些配置并注册对应的低代码仓储。 + *

+ * 配置示例: + *

{@code
+ * structure:
+ *   infra:
+ *     lowcode:
+ *       enabled: true
+ *       resources:
+ *         user:
+ *           schema:
+ *             table-name: t_user
+ *             fields:
+ *               id:
+ *                 type: long
+ *                 primary-key: true
+ *                 auto-increment: true
+ *               username:
+ *                 type: string
+ *                 length: 64
+ *                 nullable: false
+ *                 index: true
+ *           repository:
+ *             type: mysql
+ * }
+ * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@Data +@ConfigurationProperties(prefix = "structure.infra.lowcode") +public class LowCodeProperties { + + /** + * 是否启用低代码仓储 + */ + private boolean enabled = true; + + /** + * 资源定义 Map(资源名 -> 资源配置) + */ + private Map resources = new LinkedHashMap<>(); + + /** + * 单个资源配置 + */ + @Data + public static class ResourceProperties { + + /** + * 数据结构定义 + */ + private SchemaProperties schema; + + /** + * 仓储配置 + */ + private RepositoryProperties repository; + } + + /** + * 数据结构配置 + */ + @Data + public static class SchemaProperties { + + /** + * 表名/集合名 + */ + private String tableName; + + /** + * 主键类型(long/string/objectId 等) + */ + private String idType = "long"; + + /** + * 字段定义 Map(字段名 -> 字段配置) + */ + private Map fields = new LinkedHashMap<>(); + } + + /** + * 字段配置 + */ + @Data + public static class FieldProperties { + + /** + * 字段类型(string/long/int/boolean/decimal/datetime/date/text/json) + */ + private String type = "string"; + + /** + * 字段长度(字符串类型有效) + */ + private int length = 255; + + /** + * 精度(十进制类型有效,总位数) + */ + private int precision = 10; + + /** + * 小数位数(十进制类型有效) + */ + private int scale = 2; + + /** + * 是否主键 + */ + private boolean primaryKey; + + /** + * 是否自增(仅数值主键有效) + */ + private boolean autoIncrement; + + /** + * 是否允许为空 + */ + private boolean nullable = true; + + /** + * 是否唯一约束 + */ + private boolean unique; + + /** + * 是否创建索引 + */ + private boolean index; + + /** + * 默认值 + */ + private String defaultValue; + + /** + * 自动填充策略(none/create/update/create_update) + */ + private String autoFill = "none"; + + /** + * 字段描述 + */ + private String description; + } + + /** + * 仓储配置 + */ + @Data + public static class RepositoryProperties { + + /** + * 存储类型(mysql/mongodb/elasticsearch/redis) + */ + private String type = "mysql"; + + /** + * 数据源名称 + */ + private String datasource; + + /** + * CQRS 读写分离配置 + */ + private CqrsProperties cqrs; + + /** + * 缓存配置 + */ + private CacheProperties cache; + } + + /** + * CQRS 配置 + */ + @Data + public static class CqrsProperties { + + /** + * 是否启用 + */ + private boolean enabled; + + /** + * 读存储类型 + */ + private String readType; + + /** + * 读数据源名称 + */ + private String readDatasource; + } + + /** + * 缓存配置 + */ + @Data + public static class CacheProperties { + + /** + * 是否启用 + */ + private boolean enabled; + + /** + * 过期时间 + */ + private long ttl = 300; + + /** + * 时间单位(seconds/minutes/hours/days) + */ + private String timeUnit = "seconds"; + } +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/registry/ResourceSchemaBuilder.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/registry/ResourceSchemaBuilder.java new file mode 100644 index 0000000..8f395bc --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/registry/ResourceSchemaBuilder.java @@ -0,0 +1,175 @@ +package cn.structure.infra.lowcode.registry; + +import cn.structure.infra.lowcode.model.*; +import cn.structure.infra.lowcode.properties.LowCodeProperties; + +import java.util.concurrent.TimeUnit; + +/** + * 资源 Schema 构建器 + *

+ * 负责将配置属性({@link LowCodeProperties})转换为框架内部使用的 + * {@link ResourceSchema} 和 {@link RepositoryConfig} 模型对象。 + *

+ * 主要完成字符串配置值到枚举类型的转换,以及默认值的填充。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +public class ResourceSchemaBuilder { + + /** + * 从配置属性构建资源 Schema + * + * @param resourceName 资源名称 + * @param schemaProps Schema 配置属性 + * @return 资源 Schema + */ + public static ResourceSchema buildSchema(String resourceName, LowCodeProperties.SchemaProperties schemaProps) { + ResourceSchema schema = new ResourceSchema(); + schema.setResourceName(resourceName); + schema.setTableName(schemaProps.getTableName() != null ? schemaProps.getTableName() : resourceName); + + if (schemaProps.getFields() != null) { + for (var entry : schemaProps.getFields().entrySet()) { + String fieldName = entry.getKey(); + LowCodeProperties.FieldProperties fieldProps = entry.getValue(); + FieldSchema field = buildField(fieldName, fieldProps); + schema.addField(field); + } + } + + return schema; + } + + /** + * 从配置属性构建字段 Schema + * + * @param fieldName 字段名称 + * @param fieldProps 字段配置属性 + * @return 字段 Schema + */ + public static FieldSchema buildField(String fieldName, LowCodeProperties.FieldProperties fieldProps) { + FieldSchema field = new FieldSchema(); + field.setName(fieldName); + field.setType(parseFieldType(fieldProps.getType())); + field.setLength(fieldProps.getLength()); + field.setPrecision(fieldProps.getPrecision()); + field.setScale(fieldProps.getScale()); + field.setPrimaryKey(fieldProps.isPrimaryKey()); + field.setAutoIncrement(fieldProps.isAutoIncrement()); + field.setNullable(fieldProps.isNullable()); + field.setUnique(fieldProps.isUnique()); + field.setIndex(fieldProps.isIndex()); + field.setDefaultValue(fieldProps.getDefaultValue()); + field.setAutoFill(parseAutoFillType(fieldProps.getAutoFill())); + field.setDescription(fieldProps.getDescription()); + return field; + } + + /** + * 从配置属性构建仓储配置 + * + * @param repoProps 仓储配置属性 + * @return 仓储配置 + */ + public static RepositoryConfig buildRepositoryConfig(LowCodeProperties.RepositoryProperties repoProps) { + RepositoryConfig config = new RepositoryConfig(); + config.setType(parseStorageType(repoProps.getType())); + config.setDatasource(repoProps.getDatasource()); + + if (repoProps.getCqrs() != null) { + CqrsConfig cqrsConfig = new CqrsConfig(); + cqrsConfig.setEnabled(repoProps.getCqrs().isEnabled()); + cqrsConfig.setReadType(parseStorageType(repoProps.getCqrs().getReadType())); + cqrsConfig.setReadDatasource(repoProps.getCqrs().getReadDatasource()); + config.setCqrs(cqrsConfig); + } + + if (repoProps.getCache() != null) { + CacheConfig cacheConfig = new CacheConfig(); + cacheConfig.setEnabled(repoProps.getCache().isEnabled()); + cacheConfig.setTtl(repoProps.getCache().getTtl()); + cacheConfig.setTimeUnit(parseTimeUnit(repoProps.getCache().getTimeUnit())); + config.setCache(cacheConfig); + } + + return config; + } + + /** + * 解析字段类型字符串为枚举 + * + * @param type 类型字符串 + * @return 字段类型枚举 + */ + private static FieldType parseFieldType(String type) { + if (type == null) return FieldType.STRING; + return switch (type.toLowerCase()) { + case "string", "varchar" -> FieldType.STRING; + case "long", "bigint" -> FieldType.LONG; + case "int", "integer" -> FieldType.INTEGER; + case "bool", "boolean" -> FieldType.BOOLEAN; + case "decimal", "double", "float" -> FieldType.DECIMAL; + case "datetime", "timestamp" -> FieldType.DATETIME; + case "date" -> FieldType.DATE; + case "objectid", "object_id" -> FieldType.OBJECT_ID; + case "text" -> FieldType.TEXT; + case "json" -> FieldType.JSON; + default -> FieldType.STRING; + }; + } + + /** + * 解析存储类型字符串为枚举 + * + * @param type 类型字符串 + * @return 存储类型枚举 + */ + private static StorageType parseStorageType(String type) { + if (type == null) return StorageType.MYSQL; + return switch (type.toLowerCase()) { + case "mysql" -> StorageType.MYSQL; + case "mongodb", "mongo" -> StorageType.MONGODB; + case "elasticsearch", "es" -> StorageType.ELASTICSEARCH; + case "redis" -> StorageType.REDIS; + case "memory", "in_memory" -> StorageType.IN_MEMORY; + default -> StorageType.MYSQL; + }; + } + + /** + * 解析自动填充类型字符串为枚举 + * + * @param type 类型字符串 + * @return 自动填充类型枚举 + */ + private static AutoFillType parseAutoFillType(String type) { + if (type == null) return AutoFillType.NONE; + return switch (type.toLowerCase()) { + case "create", "insert" -> AutoFillType.CREATE; + case "update" -> AutoFillType.UPDATE; + case "create_update", "insert_update", "both" -> AutoFillType.CREATE_UPDATE; + default -> AutoFillType.NONE; + }; + } + + /** + * 解析时间单位字符串为枚举 + * + * @param unit 单位字符串 + * @return 时间单位枚举 + */ + private static TimeUnit parseTimeUnit(String unit) { + if (unit == null) return TimeUnit.SECONDS; + return switch (unit.toLowerCase()) { + case "seconds", "second", "s" -> TimeUnit.SECONDS; + case "minutes", "minute", "m" -> TimeUnit.MINUTES; + case "hours", "hour", "h" -> TimeUnit.HOURS; + case "days", "day", "d" -> TimeUnit.DAYS; + case "milliseconds", "ms" -> TimeUnit.MILLISECONDS; + default -> TimeUnit.SECONDS; + }; + } +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/repository/LowCodeRepoFactory.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/repository/LowCodeRepoFactory.java new file mode 100644 index 0000000..b3fdeab --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/repository/LowCodeRepoFactory.java @@ -0,0 +1,36 @@ +package cn.structure.infra.lowcode.repository; + +import cn.structure.infra.lowcode.model.RepositoryConfig; +import cn.structure.infra.lowcode.model.ResourceSchema; +import cn.structure.infra.lowcode.model.StorageType; + +/** + * 低代码仓储工厂接口 + *

+ * 定义低代码仓储工厂的契约,每种存储引擎(MySQL、MongoDB、Elasticsearch 等) + * 都需要提供对应的工厂实现,负责创建具体的 {@link LowCodeStorage} 实例。 + *

+ * 工厂实例由 Spring 容器管理,路由引擎通过 {@link StorageType} 查找对应的工厂。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +public interface LowCodeRepoFactory { + + /** + * 获取存储类型 + * + * @return 存储类型枚举 + */ + StorageType getType(); + + /** + * 创建低代码存储实例 + * + * @param schema 资源 schema 定义 + * @param config 仓储配置 + * @return 存储实例 + */ + LowCodeStorage createStorage(ResourceSchema schema, RepositoryConfig config); +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/repository/LowCodeRepository.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/repository/LowCodeRepository.java new file mode 100644 index 0000000..c131531 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/repository/LowCodeRepository.java @@ -0,0 +1,174 @@ +package cn.structure.infra.lowcode.repository; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 低代码统一仓储接口 + *

+ * 低代码仓储体系的用户侧统一入口,方法名与 {@link cn.structure.common.repository.ICrudRepository} + * 保持一致,仅在第一个参数增加资源名称以标识操作的资源。 + *

+ * 与传统泛型仓储的区别: + *

    + *
  • 无需定义实体类和 PO 类,通过资源名称和 Map 操作数据
  • + *
  • 资源结构通过 DSL(配置文件或 API)动态定义
  • + *
  • 框架根据配置自动路由到对应的存储引擎
  • + *
  • 支持运行时动态注册新资源
  • + *
+ *

+ * 使用示例: + *

{@code
+ * // 保存数据
+ * Map user = new HashMap<>();
+ * user.put("username", "zhangsan");
+ * user.put("email", "zhangsan@example.com");
+ * lowCodeRepository.save("user", user);
+ *
+ * // 查询数据
+ * Map found = lowCodeRepository.findById("user", 1L);
+ *
+ * // 分页查询
+ * ReqPage reqPage = new ReqPage();
+ * reqPage.setPage(1);
+ * reqPage.setSize(10);
+ * ResPage> page = lowCodeRepository.queryPage("user", reqPage);
+ * }
+ * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +public interface LowCodeRepository { + + /** + * 保存实体(新增或更新) + *

+ * 如果数据中包含主键且主键对应的数据已存在,则执行更新; + * 否则执行新增。 + * + * @param resourceName 资源名称 + * @param data 数据 Map + * @return 保存后的数据(包含自动生成的主键和自动填充字段) + */ + Map save(String resourceName, Map data); + + /** + * 根据 ID 删除 + * + * @param resourceName 资源名称 + * @param id 主键值 + */ + void removeById(String resourceName, Object id); + + /** + * 根据 ID 查询 + * + * @param resourceName 资源名称 + * @param id 主键值 + * @return 数据 Map,不存在时返回 null + */ + Map findById(String resourceName, Object id); + + /** + * 根据 ID 查询(读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param id 主键值 + * @return 数据 Map,不存在时返回 null + */ + Map queryById(String resourceName, Object id); + + /** + * 根据 ID 查询(Optional 包装,读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param id 主键值 + * @return Optional 包装的数据,不存在时返回 Optional.empty() + */ + Optional> queryByIdOptional(String resourceName, Object id); + + /** + * 条件查询单条记录(读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param queryParams 查询条件(非空字段作为等值条件) + * @return 单条数据,不存在时返回 null + */ + Map queryOne(String resourceName, Map queryParams); + + /** + * 条件查询单条记录(Optional 包装,读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param queryParams 查询条件 + * @return Optional 包装的数据 + */ + Optional> queryOneOptional(String resourceName, Map queryParams); + + /** + * 条件查询列表(读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param queryParams 查询条件,为 null 时查询全部 + * @return 数据列表,永远不为 null + */ + List> queryList(String resourceName, Map queryParams); + + /** + * 分页查询(读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param reqPage 分页参数 + * @return 分页结果 + */ + ResPage> queryPage(String resourceName, ReqPage reqPage); + + /** + * 批量保存 + * + * @param resourceName 资源名称 + * @param dataList 数据列表 + * @return 保存后的数据列表 + */ + List> saveBatch(String resourceName, List> dataList); + + /** + * 根据 ID 批量删除 + * + * @param resourceName 资源名称 + * @param ids 主键列表 + */ + void removeBatchByIds(String resourceName, List ids); + + /** + * 根据 ID 列表批量查询(读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param ids 主键列表 + * @return 数据列表,永远不为 null + */ + List> listByIds(String resourceName, List ids); + + /** + * 统计数量(读操作,支持 CQRS 路由) + * + * @param resourceName 资源名称 + * @param queryParams 查询条件,为 null 时统计全部 + * @return 记录数量 + */ + long count(String resourceName, Map queryParams); + + /** + * 判断是否存在(写操作,走基础存储) + * + * @param resourceName 资源名称 + * @param queryParams 查询条件 + * @return true 表示存在 + */ + boolean exists(String resourceName, Map queryParams); +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/repository/LowCodeStorage.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/repository/LowCodeStorage.java new file mode 100644 index 0000000..69cd722 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/repository/LowCodeStorage.java @@ -0,0 +1,134 @@ +package cn.structure.infra.lowcode.repository; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 低代码存储操作接口 + *

+ * 低代码仓储体系的内部操作接口,定义具体存储引擎需要实现的操作契约。 + * 与 {@link LowCodeRepository} 的区别是缺少 resourceName 参数, + * 因为每个 LowCodeStorage 实例只对应一个资源。 + *

+ * 各存储引擎(MySQL、MongoDB、Elasticsearch 等)通过实现此接口 + * 提供具体的存储操作,由 {@link LowCodeRepoFactory} 负责创建实例。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +public interface LowCodeStorage { + + /** + * 初始化存储(建表/建集合/建索引等) + *

+ * 在资源注册时调用,确保存储容器存在。如果已存在则跳过。 + */ + void initialize(); + + /** + * 保存数据(新增或更新) + * + * @param data 数据 Map + * @return 保存后的数据 + */ + Map save(Map data); + + /** + * 根据 ID 删除 + * + * @param id 主键值 + */ + void removeById(Object id); + + /** + * 根据 ID 查询(写操作路径,走基础存储) + * + * @param id 主键值 + * @return 数据 Map + */ + Map findById(Object id); + + /** + * 根据 ID 查询(读操作路径,可走读存储) + * + * @param id 主键值 + * @return 数据 Map + */ + Map queryById(Object id); + + /** + * 条件查询单条 + * + * @param queryParams 查询条件 + * @return 单条数据 + */ + Map queryOne(Map queryParams); + + /** + * 条件查询单条(Optional 包装) + * + * @param queryParams 查询条件 + * @return Optional 包装的数据 + */ + Optional> queryOneOptional(Map queryParams); + + /** + * 条件查询列表 + * + * @param queryParams 查询条件 + * @return 数据列表 + */ + List> queryList(Map queryParams); + + /** + * 分页查询 + * + * @param reqPage 分页参数 + * @return 分页结果 + */ + ResPage> queryPage(ReqPage reqPage); + + /** + * 批量保存 + * + * @param dataList 数据列表 + * @return 保存后的数据列表 + */ + List> saveBatch(List> dataList); + + /** + * 根据 ID 批量删除 + * + * @param ids 主键列表 + */ + void removeBatchByIds(List ids); + + /** + * 根据 ID 列表批量查询 + * + * @param ids 主键列表 + * @return 数据列表 + */ + List> listByIds(List ids); + + /** + * 统计数量 + * + * @param queryParams 查询条件 + * @return 记录数量 + */ + long count(Map queryParams); + + /** + * 判断是否存在 + * + * @param queryParams 查询条件 + * @return true 表示存在 + */ + boolean exists(Map queryParams); +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/router/LowCodeRepositoryRouter.java b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/router/LowCodeRepositoryRouter.java new file mode 100644 index 0000000..3ad4bb7 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/lowcode/router/LowCodeRepositoryRouter.java @@ -0,0 +1,309 @@ +package cn.structure.infra.lowcode.router; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structure.infra.lowcode.model.RepositoryConfig; +import cn.structure.infra.lowcode.model.ResourceSchema; +import cn.structure.infra.lowcode.model.StorageType; +import cn.structure.infra.lowcode.repository.LowCodeRepoFactory; +import cn.structure.infra.lowcode.repository.LowCodeRepository; +import cn.structure.infra.lowcode.repository.LowCodeStorage; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * 低代码仓储路由引擎 + *

+ * 低代码仓储体系的核心调度器,实现 {@link LowCodeRepository} 接口, + * 负责将资源名称路由到对应的存储实现,并提供 CQRS 读写分离和回退机制。 + *

+ * 核心职责: + *

    + *
  • 资源注册:接收资源定义,创建对应的存储实例
  • + *
  • 路由调度:根据资源名称查找对应的存储实例
  • + *
  • CQRS 读写分离:读操作优先使用读存储,写操作使用基础存储
  • + *
  • 故障回退:读存储异常时自动回退到基础存储
  • + *
+ *

+ * 与 {@link cn.structure.infra.repository.RepositoryFacade} 的设计理念一致, + * 区别在于低代码体系使用资源名称而非泛型类型来标识操作对象。 + * + * @author chuck + * @version 1.0.0 + * @since 2026/6/29 + */ +@Slf4j +public class LowCodeRepositoryRouter implements LowCodeRepository { + + /** + * 存储持有者注册表(资源名 -> 存储持有者) + */ + private final Map storageRegistry = new ConcurrentHashMap<>(); + + /** + * 仓储工厂映射(存储类型 -> 工厂) + */ + private final Map factoryMap; + + /** + * 构造函数 + * + * @param factories 所有可用的仓储工厂列表 + */ + public LowCodeRepositoryRouter(List factories) { + this.factoryMap = new ConcurrentHashMap<>(); + for (LowCodeRepoFactory factory : factories) { + this.factoryMap.put(factory.getType(), factory); + } + } + + /** + * 注册资源 + *

+ * 根据资源 schema 和仓储配置创建对应的存储实例,并初始化存储容器(建表/建集合)。 + * 如果配置了 CQRS 读写分离,会同时创建读存储实例。 + * + * @param resourceName 资源名称 + * @param schema 资源 schema 定义 + * @param config 仓储配置 + */ + public void registerResource(String resourceName, ResourceSchema schema, RepositoryConfig config) { + LowCodeStorage baseStorage = createStorage(schema, config.getType(), config); + LowCodeStorage readStorage = null; + + if (config.isCqrsEnabled() && config.getCqrs().getReadType() != null) { + try { + RepositoryConfig readConfig = new RepositoryConfig(); + readConfig.setType(config.getCqrs().getReadType()); + readConfig.setDatasource(config.getCqrs().getReadDatasource()); + readStorage = createStorage(schema, config.getCqrs().getReadType(), readConfig); + } catch (Exception e) { + log.warn("Failed to create read storage for resource {}, falling back to base: {}", + resourceName, e.getMessage()); + } + } + + StorageHolder holder = new StorageHolder(schema, config, baseStorage, readStorage); + storageRegistry.put(resourceName, holder); + + try { + baseStorage.initialize(); + if (readStorage != null && readStorage != baseStorage) { + readStorage.initialize(); + } + } catch (Exception e) { + log.warn("Failed to initialize storage for resource {}: {}", resourceName, e.getMessage()); + } + } + + /** + * 创建存储实例 + * + * @param schema 资源 schema + * @param type 存储类型 + * @param config 仓储配置 + * @return 存储实例 + */ + private LowCodeStorage createStorage(ResourceSchema schema, StorageType type, RepositoryConfig config) { + LowCodeRepoFactory factory = factoryMap.get(type); + if (factory == null) { + throw new IllegalArgumentException("No LowCodeRepoFactory found for type: " + type); + } + return factory.createStorage(schema, config); + } + + /** + * 获取存储持有者 + * + * @param resourceName 资源名称 + * @return 存储持有者 + * @throws IllegalArgumentException 资源未注册时抛出 + */ + private StorageHolder getHolder(String resourceName) { + StorageHolder holder = storageRegistry.get(resourceName); + if (holder == null) { + throw new IllegalArgumentException("Resource not registered: " + resourceName); + } + return holder; + } + + /** + * 执行读操作(带 CQRS 路由和回退) + *

+ * 优先使用读存储执行,失败时回退到基础存储。 + * 参考 {@link cn.structure.infra.repository.RepositoryFacade#executeReadOperation} 的设计。 + * + * @param resourceName 资源名称 + * @param readOperation 读存储操作 + * @param fallbackOperation 基础存储回退操作 + * @param 返回类型 + * @return 操作结果 + */ + private R executeRead(String resourceName, + Function readOperation, + Function fallbackOperation) { + StorageHolder holder = getHolder(resourceName); + if (holder.readStorage != null && holder.config.isCqrsEnabled()) { + try { + return readOperation.apply(holder.readStorage); + } catch (Exception e) { + log.warn("Read storage operation failed for resource {}, falling back to base: {}", + resourceName, e.getMessage()); + } + } + return fallbackOperation.apply(holder.baseStorage); + } + + /** + * 执行写操作(有返回值) + *

+ * 写操作始终走基础存储。 + * + * @param resourceName 资源名称 + * @param writeOperation 写操作函数 + * @param 返回类型 + * @return 操作结果 + */ + private R executeWrite(String resourceName, Function writeOperation) { + StorageHolder holder = getHolder(resourceName); + return writeOperation.apply(holder.baseStorage); + } + + /** + * 执行写操作(无返回值) + * + * @param resourceName 资源名称 + * @param writeOperation 写操作消费者 + */ + private void executeWriteVoid(String resourceName, java.util.function.Consumer writeOperation) { + StorageHolder holder = getHolder(resourceName); + writeOperation.accept(holder.baseStorage); + } + + @Override + public Map save(String resourceName, Map data) { + return executeWrite(resourceName, storage -> storage.save(data)); + } + + @Override + public void removeById(String resourceName, Object id) { + executeWriteVoid(resourceName, storage -> storage.removeById(id)); + } + + @Override + public Map findById(String resourceName, Object id) { + StorageHolder holder = getHolder(resourceName); + return holder.baseStorage.findById(id); + } + + @Override + public Map queryById(String resourceName, Object id) { + return executeRead(resourceName, + storage -> storage.queryById(id), + storage -> storage.queryById(id)); + } + + @Override + public Optional> queryByIdOptional(String resourceName, Object id) { + return executeRead(resourceName, + storage -> storage.queryOneOptional(buildIdQuery(resourceName, id)), + storage -> storage.queryOneOptional(buildIdQuery(resourceName, id))); + } + + /** + * 构建 ID 查询条件 Map + * + * @param resourceName 资源名称 + * @param id 主键值 + * @return 查询条件 Map + */ + private Map buildIdQuery(String resourceName, Object id) { + StorageHolder holder = getHolder(resourceName); + return Map.of(holder.schema.getIdFieldName(), id); + } + + @Override + public Map queryOne(String resourceName, Map queryParams) { + return executeRead(resourceName, + storage -> storage.queryOne(queryParams), + storage -> storage.queryOne(queryParams)); + } + + @Override + public Optional> queryOneOptional(String resourceName, Map queryParams) { + return executeRead(resourceName, + storage -> storage.queryOneOptional(queryParams), + storage -> storage.queryOneOptional(queryParams)); + } + + @Override + public List> queryList(String resourceName, Map queryParams) { + return executeRead(resourceName, + storage -> storage.queryList(queryParams), + storage -> storage.queryList(queryParams)); + } + + @Override + public ResPage> queryPage(String resourceName, ReqPage reqPage) { + return executeRead(resourceName, + storage -> storage.queryPage(reqPage), + storage -> storage.queryPage(reqPage)); + } + + @Override + public List> saveBatch(String resourceName, List> dataList) { + return executeWrite(resourceName, storage -> storage.saveBatch(dataList)); + } + + @Override + public void removeBatchByIds(String resourceName, List ids) { + executeWriteVoid(resourceName, storage -> storage.removeBatchByIds(ids)); + } + + @Override + public List> listByIds(String resourceName, List ids) { + return executeRead(resourceName, + storage -> storage.listByIds(ids), + storage -> storage.listByIds(ids)); + } + + @Override + public long count(String resourceName, Map queryParams) { + return executeRead(resourceName, + storage -> storage.count(queryParams), + storage -> storage.count(queryParams)); + } + + @Override + public boolean exists(String resourceName, Map queryParams) { + StorageHolder holder = getHolder(resourceName); + return holder.baseStorage.exists(queryParams); + } + + /** + * 存储持有者内部类 + *

+ * 封装一个资源的所有存储相关对象,包括 schema、配置、基础存储和读存储。 + */ + @Setter + private static class StorageHolder { + ResourceSchema schema; + RepositoryConfig config; + LowCodeStorage baseStorage; + LowCodeStorage readStorage; + + StorageHolder(ResourceSchema schema, RepositoryConfig config, + LowCodeStorage baseStorage, LowCodeStorage readStorage) { + this.schema = schema; + this.config = config; + this.baseStorage = baseStorage; + this.readStorage = readStorage; + } + } +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/package-info.java b/structure-infra-starter/src/main/java/cn/structure/infra/package-info.java new file mode 100644 index 0000000..1b836e9 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/package-info.java @@ -0,0 +1 @@ +package cn.structure.infra; \ No newline at end of file diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/properties/InfraProperties.java b/structure-infra-starter/src/main/java/cn/structure/infra/properties/InfraProperties.java new file mode 100644 index 0000000..53be23e --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/properties/InfraProperties.java @@ -0,0 +1,38 @@ +package cn.structure.infra.properties; + +import cn.structure.infra.event.EventChannel; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.TimeUnit; + +@Data +@Configuration +@ConfigurationProperties(prefix = "structure.infra") +public class InfraProperties { + + /** + * 默认事件类型 + */ + private EventChannel defaultEventChannel = EventChannel.SPRING_EVENT; + + /** + * 是否开启CQRS + */ + private Boolean cqrs = false; + + /** + * 缓存时间 + * + * @return + */ + private Long cacheTime = 60L; + + /** + * 缓存时间单位 + * + * @return + */ + private TimeUnit cacheTimeUnit = TimeUnit.SECONDS; +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/DelegateType.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/DelegateType.java new file mode 100644 index 0000000..95489b8 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/DelegateType.java @@ -0,0 +1,20 @@ +package cn.structure.infra.repository; + +/** + * 委托类型 + *

+ * 用于区分不同用途的 RepositoryDelegate + *

+ * - BASE: 基础代理,承担写操作和默认读操作 + * - READ: 读代理,专门承担读操作(CQRS 模式下使用) + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +public enum DelegateType { + + BASE, + + READ +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/IQueryDelegate.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/IQueryDelegate.java new file mode 100644 index 0000000..ad1e04b --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/IQueryDelegate.java @@ -0,0 +1,61 @@ +package cn.structure.infra.repository; + +import cn.structure.common.repository.IQueryRepository; + +import java.util.List; + +/** + * 只读仓储委托接口 + *

+ * 继承公共库的 IQueryRepository,表示读仓库的委托能力。 + * 用于 CQRS 模式下,读代理只需要实现读操作,不需要实现写操作。 + *

+ * 这是防腐层(ACL)的核心组件之一: + * - 对外:由 RepositoryFacade 调用,面向领域模型 + * - 对内:操作持久化模型(PO),与具体存储技术交互 + *

+ * 与 RepositoryDelegate 的关系: + * - RepositoryDelegate:继承 ICrudRepository + IQueryDelegate,包含完整的 CRUD 操作(写+读) + * - IQueryDelegate:只继承 IQueryRepository + 补充方法,只有读操作 + * - RepositoryDelegate 是 IQueryDelegate 的子类型,因此所有 RepositoryDelegate 都可以作为读代理使用 + * + * @param 持久化对象类型(PO) + * @param 主键类型 + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +public interface IQueryDelegate extends IQueryRepository { + + /** + * 根据主键查询 + * + * @param id 主键 + * @return 实体对象 + */ + T findById(ID id); + + /** + * 根据主键列表批量查询 + * + * @param ids 主键列表 + * @return 实体列表 + */ + List listByIds(List ids); + + /** + * 统计数量 + * + * @param entity 查询条件(非空属性作为条件) + * @return 数量 + */ + long count(T entity); + + /** + * 判断是否存在 + * + * @param entity 查询条件(非空属性作为条件) + * @return true 存在 + */ + boolean exists(T entity); +} \ No newline at end of file diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/InMemoryRepositoryDelegate.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/InMemoryRepositoryDelegate.java new file mode 100644 index 0000000..b235c88 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/InMemoryRepositoryDelegate.java @@ -0,0 +1,280 @@ +package cn.structure.infra.repository; + +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; + +import java.lang.reflect.Field; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +/** + * 内存版仓储委托实现 + *

+ * 基于 ConcurrentHashMap 实现的内存仓储,用于示例、测试和开发阶段 + * 支持基本的 CRUD、条件查询、分页查询 + * + * @param 实体类型 + * @param 主键类型 + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@Slf4j +public class InMemoryRepositoryDelegate implements RepositoryDelegate { + + private final Map storage = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + private final Class entityClass; + private final String idFieldName; + + public InMemoryRepositoryDelegate(Class entityClass) { + this(entityClass, "id"); + } + + public InMemoryRepositoryDelegate(Class entityClass, String idFieldName) { + this.entityClass = entityClass; + this.idFieldName = idFieldName; + log.info("InMemoryRepositoryDelegate initialized for entity: {}", entityClass.getSimpleName()); + } + + @Override + public T save(T entity) { + if (entity == null) { + return null; + } + ID id = getIdValue(entity); + if (id == null) { + id = generateId(); + setIdValue(entity, id); + } + storage.put(id, entity); + log.debug("Saved entity: id={}, entity={}", id, entity); + return entity; + } + + @Override + public void removeById(ID id) { + if (id != null) { + T removed = storage.remove(id); + log.debug("Removed entity: id={}, removed={}", id, removed != null); + } + } + + @Override + public T findById(ID id) { + if (id == null) { + return null; + } + T entity = storage.get(id); + log.debug("Find by id: id={}, found={}", id, entity != null); + return entity; + } + + @Override + public T queryById(ID id) { + return findById(id); + } + + @Override + public Optional queryByIdOptional(ID id) { + return Optional.ofNullable(queryById(id)); + } + + @Override + public T queryOne(T condition) { + if (condition == null) { + return null; + } + List results = queryList(condition); + return results.isEmpty() ? null : results.get(0); + } + + @Override + public Optional queryOneOptional(T condition) { + return Optional.ofNullable(queryOne(condition)); + } + + @Override + public List queryList(T condition) { + if (condition == null) { + return new ArrayList<>(storage.values()); + } + return storage.values().stream() + .filter(entity -> matchesCondition(entity, condition)) + .collect(Collectors.toList()); + } + + @Override + public ResPage queryPage(ReqPage reqPage) { + ResPage page = new ResPage<>(); + List allValues = new ArrayList<>(storage.values()); + long total = allValues.size(); + int pageNum = reqPage.getPage() != null ? reqPage.getPage() : 1; + int pageSize = reqPage.getSize() != null ? reqPage.getSize() : 10; + + long pages = total > 0 ? (total + pageSize - 1) / pageSize : 0; + int fromIndex = (pageNum - 1) * pageSize; + int toIndex = Math.min(fromIndex + pageSize, allValues.size()); + + List records = (fromIndex >= allValues.size()) + ? List.of() + : allValues.subList(fromIndex, toIndex); + + page.setCurrent((long) pageNum); + page.setPages(pages); + page.setSize((long) pageSize); + page.setTotal(total); + page.setRecords(records); + + log.debug("Query page: page={}, size={}, total={}, records={}", + pageNum, pageSize, total, records.size()); + return page; + } + + /** + * 检查实体是否匹配条件 + *

+ * 通过反射比较非空字段的值 + */ + private boolean matchesCondition(T entity, T condition) { + try { + Field[] fields = getAllFields(entity.getClass()); + for (Field field : fields) { + field.setAccessible(true); + Object conditionValue = field.get(condition); + if (conditionValue != null) { + Object entityValue = field.get(entity); + if (!conditionValue.equals(entityValue)) { + return false; + } + } + } + return true; + } catch (Exception e) { + log.warn("Error matching condition: {}", e.getMessage()); + return false; + } + } + + private Field[] getAllFields(Class clazz) { + List fields = new ArrayList<>(); + while (clazz != null && clazz != Object.class) { + fields.addAll(Arrays.asList(clazz.getDeclaredFields())); + clazz = clazz.getSuperclass(); + } + return fields.toArray(new Field[0]); + } + + @SuppressWarnings("unchecked") + private ID getIdValue(T entity) { + try { + Field field = findIdField(entity.getClass()); + if (field != null) { + field.setAccessible(true); + return (ID) field.get(entity); + } + } catch (Exception e) { + log.warn("Error getting id value: {}", e.getMessage()); + } + return null; + } + + private void setIdValue(T entity, ID id) { + try { + Field field = findIdField(entity.getClass()); + if (field != null) { + field.setAccessible(true); + if (field.getType() == Long.class || field.getType() == long.class) { + field.set(entity, id); + } else if (field.getType() == Integer.class || field.getType() == int.class) { + field.set(entity, ((Number) id).intValue()); + } else if (field.getType() == String.class) { + field.set(entity, String.valueOf(id)); + } else { + field.set(entity, id); + } + } + } catch (Exception e) { + log.warn("Error setting id value: {}", e.getMessage()); + } + } + + private Field findIdField(Class clazz) { + try { + Field field = clazz.getDeclaredField(idFieldName); + return field; + } catch (NoSuchFieldException e) { + if (clazz.getSuperclass() != null && clazz.getSuperclass() != Object.class) { + return findIdField(clazz.getSuperclass()); + } + return null; + } + } + + @SuppressWarnings("unchecked") + private ID generateId() { + return (ID) Long.valueOf(idGenerator.getAndIncrement()); + } + + /** + * 获取存储大小(用于测试) + */ + public int size() { + return storage.size(); + } + + /** + * 清空存储(用于测试) + */ + public void clear() { + storage.clear(); + idGenerator.set(1); + } + + @Override + public List saveBatch(List entities) { + if (entities == null || entities.isEmpty()) { + return List.of(); + } + return entities.stream() + .map(this::save) + .collect(Collectors.toList()); + } + + @Override + public void removeBatchByIds(List ids) { + if (ids != null) { + ids.forEach(this::removeById); + } + } + + @Override + public List listByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + return ids.stream() + .map(this::findById) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + @Override + public long count(T condition) { + if (condition == null) { + return storage.size(); + } + return storage.values().stream() + .filter(entity -> matchesCondition(entity, condition)) + .count(); + } + + @Override + public boolean exists(T condition) { + return count(condition) > 0; + } +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryBeanPostProcessor.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryBeanPostProcessor.java new file mode 100644 index 0000000..c3affa4 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryBeanPostProcessor.java @@ -0,0 +1,339 @@ +package cn.structure.infra.repository; + +import cn.structure.infra.annotations.DelegateFor; +import cn.structure.infra.annotations.Repository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +@Slf4j +public class RepositoryBeanPostProcessor implements BeanPostProcessor, ApplicationContextAware, ApplicationListener { + + private ApplicationContext applicationContext; + + private final List delegateInfos = new ArrayList<>(); + + private final List facadeInfos = new ArrayList<>(); + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + DelegateFor annotation = bean.getClass().getAnnotation(DelegateFor.class); + if (annotation != null) { + DelegateInfo info = new DelegateInfo(); + info.beanName = beanName; + info.name = annotation.name(); + info.type = annotation.type(); + info.poClass = annotation.po(); + info.priority = annotation.priority(); + info.description = annotation.description(); + info.delegateClass = bean.getClass(); + info.delegateType = annotation.delegateType(); + + if (bean instanceof RepositoryDelegate) { + info.delegate = (RepositoryDelegate) bean; + if (bean instanceof IQueryDelegate) { + info.queryDelegate = (IQueryDelegate) bean; + } + log.info("Found RepositoryDelegate: name={}, type={}, delegateType={}, poClass={}, delegateClass={}, priority={}", + info.name, info.type, info.delegateType, + info.poClass != null ? info.poClass.getSimpleName() : "null", + info.delegateClass.getSimpleName(), + info.priority); + } + if (bean instanceof IQueryDelegate) { + info.queryDelegate = (IQueryDelegate) bean; + log.info("Found IQueryDelegate: name={}, type={}, delegateType={}, poClass={}, delegateClass={}, priority={}", + info.name, info.type, info.delegateType, + info.poClass != null ? info.poClass.getSimpleName() : "null", + info.delegateClass.getSimpleName(), + info.priority); + } + delegateInfos.add(info); + } + return bean; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof RepositoryFacade) { + RepositoryFacadeInfo info = new RepositoryFacadeInfo(); + info.facade = (RepositoryFacade) bean; + info.beanName = beanName; + facadeInfos.add(info); + } + return bean; + } + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + for (RepositoryFacadeInfo facadeInfo : facadeInfos) { + injectDelegatesToFacade(facadeInfo.facade, facadeInfo.beanName); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private void injectDelegatesToFacade(RepositoryFacade facade, String beanName) { + try { + Class[] genericTypes = getGenericTypes(facade.getClass()); + if (genericTypes.length < 4) { + log.debug("RepositoryFacade '{}' has insufficient generic types (need 4, got {})", beanName, genericTypes.length); + return; + } + + Class entityClass = genericTypes[0]; + Class idClass = genericTypes[1]; + Class poClass = genericTypes[2]; + Class delegateClass = genericTypes[3]; + + Repository repositoryAnnotation = facade.getClass().getAnnotation(Repository.class); + RepositoryType targetType = repositoryAnnotation != null ? repositoryAnnotation.type() : RepositoryType.AUTO; + boolean cqrsEnabled = repositoryAnnotation != null && repositoryAnnotation.cqrs(); + Class readDelegateClass = repositoryAnnotation != null ? repositoryAnnotation.readDelegateClass() : Object.class; + + log.debug("RepositoryFacade '{}' requires: entity={}, id={}, po={}, delegateClass={}, type={}, cqrs={}, readDelegateClass={}", + beanName, entityClass.getSimpleName(), idClass.getSimpleName(), + poClass.getSimpleName(), delegateClass.getSimpleName(), targetType, cqrsEnabled, + readDelegateClass != null ? readDelegateClass.getSimpleName() : "null"); + + DelegateInfo baseDelegateInfo = findBaseDelegate(beanName, poClass, delegateClass, targetType); + + if (baseDelegateInfo != null && baseDelegateInfo.delegate != null) { + facade.setBaseDelegate(baseDelegateInfo.delegate); + log.info("Injected BASE delegate '{}' (type={}) into RepositoryFacade '{}'", + baseDelegateInfo.beanName, baseDelegateInfo.type, beanName); + } else { + log.debug("No matching BASE delegate found for RepositoryFacade '{}', trying to auto-create delegate via factory", beanName); + RepositoryDelegate autoDelegate = autoCreateDelegate(poClass, idClass, targetType); + + if (autoDelegate != null) { + facade.setBaseDelegate(autoDelegate); + log.info("Auto-created BASE delegate (type={}) for RepositoryFacade '{}'", targetType, beanName); + } else { + log.warn("No matching BASE delegate found for RepositoryFacade '{}', using default InMemoryRepositoryDelegate", beanName); + RepositoryDelegate defaultDelegate = new InMemoryRepositoryDelegate(poClass); + facade.setBaseDelegate(defaultDelegate); + } + } + + boolean shouldEnableReadDelegate = cqrsEnabled && readDelegateClass != null && readDelegateClass != Object.class; + if (shouldEnableReadDelegate) { + // READ 代理使用 AUTO 类型匹配,因为 CQRS 模式下读代理可能与写代理类型不同 + // 例如:写代理用 MyBatis Plus,读代理用 Elasticsearch + DelegateInfo readDelegateInfo = findReadDelegate(beanName, poClass, readDelegateClass, RepositoryType.AUTO); + + if (readDelegateInfo != null && readDelegateInfo.queryDelegate != null) { + facade.setReadDelegate(readDelegateInfo.queryDelegate); + log.info("Injected READ delegate '{}' (type={}) into RepositoryFacade '{}'", + readDelegateInfo.beanName, readDelegateInfo.type, beanName); + } else { + log.warn("No matching READ delegate found for RepositoryFacade '{}' (readDelegateClass={}), read operations will use BASE delegate", + beanName, readDelegateClass.getSimpleName()); + } + } + + facade.setEntityClass(entityClass); + facade.setPoClass(poClass); + + } catch (Exception e) { + log.warn("Error injecting delegates to RepositoryFacade '{}': {}", beanName, e.getMessage()); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private RepositoryDelegate autoCreateDelegate(Class poClass, Class idClass, RepositoryType targetType) { + try { + Map factories = applicationContext.getBeansOfType(RepositoryDelegateFactory.class); + + if (factories.isEmpty()) { + log.debug("No RepositoryDelegateFactory beans found in application context"); + return null; + } + + if (targetType != RepositoryType.AUTO) { + for (RepositoryDelegateFactory factory : factories.values()) { + if (factory.getType() == targetType) { + RepositoryDelegate delegate = factory.createDelegate(poClass, idClass); + if (delegate != null) { + return delegate; + } + } + } + } else { + for (RepositoryDelegateFactory factory : factories.values()) { + try { + RepositoryDelegate delegate = factory.createDelegate(poClass, idClass); + if (delegate != null) { + log.debug("Auto-created delegate using factory for type: {}", factory.getType()); + return delegate; + } + } catch (Exception e) { + log.debug("Factory {} failed to create delegate: {}", factory.getType(), e.getMessage()); + } + } + } + } catch (Exception e) { + log.debug("Failed to auto-create delegate: {}", e.getMessage()); + } + return null; + } + + private DelegateInfo findBaseDelegate(String beanName, Class poClass, Class delegateClass, RepositoryType targetType) { + List filtered = delegateInfos.stream() + .filter(info -> info.delegateType == DelegateType.BASE && info.delegate != null) + .sorted(Comparator.comparingInt((DelegateInfo i) -> i.priority).reversed()) + .toList(); + + for (DelegateInfo info : filtered) { + if (delegateClass.isAssignableFrom(info.delegateClass) + && info.name != null && !info.name.isEmpty() && info.name.equals(beanName) + && (targetType == RepositoryType.AUTO || targetType == info.type)) { + return info; + } + } + + for (DelegateInfo info : filtered) { + if (delegateClass.isAssignableFrom(info.delegateClass) + && (targetType == RepositoryType.AUTO || targetType == info.type)) { + return info; + } + } + + for (DelegateInfo info : filtered) { + if (delegateClass.isAssignableFrom(info.delegateClass) + && info.name != null && !info.name.isEmpty() && info.name.equals(beanName)) { + return info; + } + } + + for (DelegateInfo info : filtered) { + if (delegateClass.isAssignableFrom(info.delegateClass)) { + return info; + } + } + + for (DelegateInfo info : filtered) { + if (info.name != null && !info.name.isEmpty() && info.name.equals(beanName)) { + return info; + } + } + + for (DelegateInfo info : filtered) { + if (info.poClass != null && info.poClass.equals(poClass)) { + return info; + } + } + + return null; + } + + private DelegateInfo findReadDelegate(String beanName, Class poClass, Class readDelegateClass, RepositoryType targetType) { + List filtered = delegateInfos.stream() + .filter(info -> info.delegateType == DelegateType.READ && info.queryDelegate != null) + .sorted(Comparator.comparingInt((DelegateInfo i) -> i.priority).reversed()) + .toList(); + + for (DelegateInfo info : filtered) { + if (readDelegateClass.isAssignableFrom(info.delegateClass) + && info.name != null && !info.name.isEmpty() && info.name.equals(beanName) + && (targetType == RepositoryType.AUTO || targetType == info.type)) { + return info; + } + } + + for (DelegateInfo info : filtered) { + if (readDelegateClass.isAssignableFrom(info.delegateClass) + && (targetType == RepositoryType.AUTO || targetType == info.type)) { + return info; + } + } + + for (DelegateInfo info : filtered) { + if (readDelegateClass.isAssignableFrom(info.delegateClass) + && info.name != null && !info.name.isEmpty() && info.name.equals(beanName)) { + return info; + } + } + + for (DelegateInfo info : filtered) { + if (readDelegateClass.isAssignableFrom(info.delegateClass)) { + return info; + } + } + + for (DelegateInfo info : filtered) { + if (info.name != null && !info.name.isEmpty() && info.name.equals(beanName)) { + return info; + } + } + + for (DelegateInfo info : filtered) { + if (info.poClass != null && info.poClass.equals(poClass)) { + return info; + } + } + + return null; + } + + private Class[] getGenericTypes(Class clazz) { + Class currentClass = clazz; + while (currentClass != null && currentClass != Object.class) { + Type superclass = currentClass.getGenericSuperclass(); + if (superclass instanceof ParameterizedType) { + ParameterizedType parameterizedType = (ParameterizedType) superclass; + Type rawType = parameterizedType.getRawType(); + if (rawType instanceof Class && RepositoryFacade.class.isAssignableFrom((Class) rawType)) { + Type[] typeArgs = parameterizedType.getActualTypeArguments(); + Class[] classes = new Class[typeArgs.length]; + for (int i = 0; i < typeArgs.length; i++) { + if (typeArgs[i] instanceof Class) { + classes[i] = (Class) typeArgs[i]; + } else if (typeArgs[i] instanceof ParameterizedType) { + Type raw = ((ParameterizedType) typeArgs[i]).getRawType(); + if (raw instanceof Class) { + classes[i] = (Class) raw; + } + } + } + return classes; + } + } + currentClass = currentClass.getSuperclass(); + } + return new Class[0]; + } + + private static class DelegateInfo { + String beanName; + String name; + RepositoryType type; + Class poClass; + int priority; + String description; + Class delegateClass; + RepositoryDelegate delegate; + IQueryDelegate queryDelegate; + DelegateType delegateType; + } + + private static class RepositoryFacadeInfo { + RepositoryFacade facade; + String beanName; + } +} \ No newline at end of file diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryDefinition.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryDefinition.java new file mode 100644 index 0000000..c1d2b80 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryDefinition.java @@ -0,0 +1,123 @@ +package cn.structure.infra.repository; + +import cn.structure.infra.annotations.Repository; +import cn.structure.infra.repository.RepositoryType; +import lombok.Data; + +import java.util.concurrent.TimeUnit; + +/** + * 仓储定义元数据 + *

+ * 封装 @Repository 注解的配置信息 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@Data +public class RepositoryDefinition { + + /** + * 仓储名称(Bean名称) + */ + private String name; + + /** + * 仓储类型 + */ + private RepositoryType type; + + /** + * 实体类类型 + */ + private Class entityClass; + + /** + * PO持久化对象类型 + */ + private Class poClass; + + /** + * 主键类型 + */ + private Class idClass; + + /** + * 仓储描述 + */ + private String description; + + /** + * 是否启用缓存 + */ + private boolean cache; + + /** + * 缓存时间 + */ + private long cacheTime; + + /** + * 缓存时间单位 + */ + private TimeUnit cacheTimeUnit; + + /** + * 是否启用 CQRS 读写分离 + *

+ * 启用后,读操作使用 readDelegate,写操作使用 baseDelegate + *

+ * 必须与 readDelegateClass 同时配置才生效 + */ + private boolean cqrs; + + /** + * 读代理类 + *

+ * 指定读操作使用的代理类,用于 CQRS 读写分离 + *

+ * 必须与 cqrs=true 同时配置才生效 + */ + private Class readDelegateClass; + + /** + * 原始注解 + */ + private Repository annotation; + + /** + * 从注解构建仓储定义 + * + * @param annotation @Repository注解实例 + * @param beanName Bean名称 + * @return 仓储定义 + */ + public static RepositoryDefinition fromAnnotation(Repository annotation, String beanName) { + RepositoryDefinition definition = new RepositoryDefinition(); + definition.setName(beanName); + definition.setType(annotation.type()); + definition.setEntityClass(annotation.entity()); + definition.setPoClass(annotation.po()); + definition.setIdClass(annotation.id()); + definition.setDescription(annotation.description()); + definition.setCache(annotation.cache()); + definition.setCacheTime(annotation.cacheTime()); + definition.setCacheTimeUnit(annotation.cacheTimeUnit()); + definition.setCqrs(annotation.cqrs()); + definition.setReadDelegateClass(annotation.readDelegateClass()); + definition.setAnnotation(annotation); + return definition; + } + + /** + * 验证配置是否有效 + * + * @return true if valid + */ + public boolean isValid() { + return entityClass != null && entityClass != Object.class + && poClass != null && poClass != Object.class; + } + +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryDelegate.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryDelegate.java new file mode 100644 index 0000000..96b40b6 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryDelegate.java @@ -0,0 +1,32 @@ +package cn.structure.infra.repository; + +import cn.structure.common.repository.ICrudRepository; + +/** + * 仓储委托接口 + *

+ * 定义持久化层的操作契约,面向持久化对象(PO)。 + * 不同的持久化技术(MyBatis、JPA、MongoDB等)提供各自的实现。 + *

+ * 这是防腐层(ACL)的核心组件之一: + * - 对外:由 RepositoryFacade 调用,面向领域模型 + * - 对内:操作持久化模型(PO),与具体存储技术交互 + *

+ * DDD 场景下,用户可以自定义实现此接口来满足特殊的持久化需求。 + *

+ * 继承关系: + *

    + *
  • 继承 {@link ICrudRepository}:提供完整 CRUD 能力(写+读)
  • + *
  • 继承 {@link IQueryDelegate}:提供只读查询能力, + * 使 RepositoryDelegate 可直接作为 CQRS 模式下的 READ 代理使用
  • + *
+ * + * @param 持久化对象类型(PO) + * @param 主键类型 + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +public interface RepositoryDelegate extends ICrudRepository, IQueryDelegate { + +} \ No newline at end of file diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryDelegateFactory.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryDelegateFactory.java new file mode 100644 index 0000000..9a86ec4 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryDelegateFactory.java @@ -0,0 +1,35 @@ +package cn.structure.infra.repository; + +import cn.structure.infra.repository.RepositoryType; + +/** + * 仓储委托工厂接口 + *

+ * 各个持久化技术的 starter 模块实现此接口, + * 用于自动创建对应的 RepositoryDelegate 实例。 + *

+ * 基础模块通过 Spring 容器查找所有实现类, + * 当找不到用户自定义的 delegate 时,使用工厂自动创建。 + * + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +public interface RepositoryDelegateFactory { + + /** + * 获取支持的仓储类型 + * + * @return 仓储类型 + */ + RepositoryType getType(); + + /** + * 创建仓储委托实例 + * + * @param poClass PO 类 + * @param idClass ID 类 + * @return 仓储委托实例,如果无法创建则返回 null + */ + RepositoryDelegate createDelegate(Class poClass, Class idClass); +} diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryFacade.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryFacade.java new file mode 100644 index 0000000..96b445f --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryFacade.java @@ -0,0 +1,266 @@ +package cn.structure.infra.repository; + +import cn.structure.common.repository.ICrudRepository; +import cn.structure.common.vo.ReqPage; +import cn.structure.common.vo.ResPage; +import cn.structured.datascope.cache.manager.DataScopeCacheManager; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; + +import java.util.List; +import java.util.Optional; + +/** + * 仓储门面 + *

+ * 作为领域层与持久化层之间的防腐层(ACL),提供统一的 CRUD 操作契约。 + *

+ * 支持两种代理: + * - baseDelegate: RepositoryDelegate,承担写操作和默认读操作 + * - readDelegate: IQueryDelegate,承担读操作(CQRS 模式下使用) + *

+ * 读操作回退机制: + * - 如果配置了 readDelegate 且 cqrs=true,读操作优先使用 readDelegate + * - 如果 readDelegate 执行失败(抛出异常),自动回退到 baseDelegate 执行 + * - baseDelegate 是最后的兜底 + * + * @param 领域实体类型 + * @param 主键类型 + * @param

持久化对象类型(PO) + * @param 基础委托类型 + * @author chuck + * @version 1.0.1 + * @since 2026/6/28 + */ +@Setter +@Getter +@Slf4j +public class RepositoryFacade> implements ICrudRepository { + + protected DataScopeCacheManager cacheManager; + + /** + * 基础代理 + *

+ * 承担所有写操作:save、removeById、saveBatch、removeBatchByIds + * 同时作为读操作的默认代理和兜底代理 + */ + protected D baseDelegate; + + /** + * 读代理(新增) + *

+ * 承担所有读操作:findById、queryList、queryPage 等 + * 如果未配置或执行失败,读操作会回退到使用 baseDelegate + * 类型为 IQueryDelegate,可以与 baseDelegate 类型不同 + */ + protected IQueryDelegate readDelegate; + + protected Class entityClass; + + protected Class

poClass; + + public RepositoryFacade() { + } + + public RepositoryFacade(Class entityClass, Class

poClass) { + this.entityClass = entityClass; + this.poClass = poClass; + } + + /** + * 获取基础代理(原有方法保持不变) + * + * @return 基础代理 + */ + public D getBaseDelegate() { + return baseDelegate; + } + + @Override + public T save(T entity) { + P save = baseDelegate.save(toPo(entity)); + return toEntity(save); + } + + @Override + public void removeById(ID id) { + baseDelegate.removeById(id); + } + + @Override + public T findById(ID id) { + P po = baseDelegate.findById(id); + return toEntity(po); + } + + @Override + public T queryById(ID id) { + P po = executeReadOperation( + () -> readDelegate.queryById(id), + () -> baseDelegate.queryById(id)); + return toEntity(po); + } + + @Override + public Optional queryByIdOptional(ID id) { + P po = executeReadOperation( + () -> readDelegate.queryById(id), + () -> baseDelegate.queryById(id)); + return Optional.ofNullable(toEntity(po)); + } + + @Override + public T queryOne(T entity) { + P p = executeReadOperation( + () -> readDelegate.queryOne(toPo(entity)), + () -> baseDelegate.queryOne(toPo(entity))); + return toEntity(p); + } + + @Override + public Optional queryOneOptional(T entity) { + Optional

p = executeReadOperation( + () -> readDelegate.queryOneOptional(toPo(entity)), + () -> baseDelegate.queryOneOptional(toPo(entity))); + return p.map(this::toEntity); + } + + @Override + public List queryList(T entity) { + List

poList = executeReadOperation( + () -> readDelegate.queryList(toPo(entity)), + () -> baseDelegate.queryList(toPo(entity))); + if (poList == null || poList.isEmpty()) { + return List.of(); + } + return poList.stream() + .map(this::toEntity) + .toList(); + } + + @Override + public ResPage queryPage(ReqPage reqPage) { + ResPage

poPage = executeReadOperation( + () -> readDelegate.queryPage(reqPage), + () -> baseDelegate.queryPage(reqPage)); + if (poPage == null) { + return null; + } + ResPage tPage = new ResPage<>(); + tPage.setCurrent(poPage.getCurrent()); + tPage.setPages(poPage.getPages()); + tPage.setSize(poPage.getSize()); + tPage.setTotal(poPage.getTotal()); + if (poPage.getRecords() != null) { + tPage.setRecords(poPage.getRecords().stream() + .map(this::toEntity) + .toList()); + } + return tPage; + } + + @Override + public List saveBatch(List entities) { + if (entities == null || entities.isEmpty()) { + return List.of(); + } + List

poList = entities.stream() + .map(this::toPo) + .toList(); + List

savedPoList = baseDelegate.saveBatch(poList); + return savedPoList.stream() + .map(this::toEntity) + .toList(); + } + + @Override + public void removeBatchByIds(List ids) { + baseDelegate.removeBatchByIds(ids); + } + + @Override + public List listByIds(List ids) { + List

poList = executeReadOperation( + () -> readDelegate.listByIds(ids), + () -> baseDelegate.listByIds(ids)); + if (poList == null || poList.isEmpty()) { + return List.of(); + } + return poList.stream() + .map(this::toEntity) + .toList(); + } + + @Override + public long count(T entity) { + return executeReadOperation( + () -> readDelegate.count(toPo(entity)), + () -> baseDelegate.count(toPo(entity))); + } + + @Override + public boolean exists(T entity) { + return baseDelegate.exists(toPo(entity)); + } + + /** + * 执行读操作,带回退机制(新增方法) + *

+ * 优先使用 readDelegate 执行,如果 readDelegate 未配置或执行失败, + * 自动回退到 baseDelegate 执行。 + * + * @param readOperation 读代理操作 + * @param fallbackOperation 基础代理回退操作 + * @param 返回类型 + * @return 操作结果 + */ + protected R executeReadOperation(ReadOperation readOperation, ReadOperation fallbackOperation) { + if (readDelegate != null) { + try { + return readOperation.execute(); + } catch (Exception e) { + log.warn("Read delegate operation failed, falling back to base delegate: {}", e.getMessage()); + } + } + return fallbackOperation.execute(); + } + + /** + * 读操作接口(新增接口) + * + * @param 返回类型 + */ + @FunctionalInterface + protected interface ReadOperation { + R execute(); + } + + protected T toEntity(P po) { + if (po == null) { + return null; + } + try { + T entity = entityClass.getDeclaredConstructor().newInstance(); + BeanUtils.copyProperties(po, entity); + return entity; + } catch (Exception e) { + throw new RuntimeException("Failed to convert PO to entity", e); + } + } + + protected P toPo(T entity) { + if (entity == null) { + return null; + } + try { + P po = poClass.getDeclaredConstructor().newInstance(); + BeanUtils.copyProperties(entity, po); + return po; + } catch (Exception e) { + throw new RuntimeException("Failed to convert entity to PO", e); + } + } +} \ No newline at end of file diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryFacadeFactoryBean.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryFacadeFactoryBean.java new file mode 100644 index 0000000..eed7a4a --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryFacadeFactoryBean.java @@ -0,0 +1,42 @@ +package cn.structure.infra.repository; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.FactoryBean; + +@Slf4j +public class RepositoryFacadeFactoryBean> implements FactoryBean> { + + private final Class entityClass; + private final Class

poClass; + private final Class delegateClass; + private final RepositoryDefinition definition; + + public RepositoryFacadeFactoryBean(Class entityClass, Class

poClass, Class delegateClass, RepositoryDefinition definition) { + this.entityClass = entityClass; + this.poClass = poClass; + this.delegateClass = delegateClass; + this.definition = definition; + } + + @Override + public RepositoryFacade getObject() { + log.debug("Creating RepositoryFacade for entity: {}, po: {}, delegate: {}", + entityClass.getName(), poClass.getName(), delegateClass.getName()); + return new RepositoryFacade<>(entityClass, poClass); + } + + @Override + @SuppressWarnings("unchecked") + public Class getObjectType() { + return RepositoryFacade.class; + } + + @Override + public boolean isSingleton() { + return true; + } + + public RepositoryDefinition getDefinition() { + return definition; + } +} \ No newline at end of file diff --git a/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryType.java b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryType.java new file mode 100644 index 0000000..ce00375 --- /dev/null +++ b/structure-infra-starter/src/main/java/cn/structure/infra/repository/RepositoryType.java @@ -0,0 +1,31 @@ +package cn.structure.infra.repository; + +/** + *

+ * 仓储类型 + *

+ * + * @author chuck + * @version 1.0.1 + * @since 2021/6/21 16:05 + */ +public enum RepositoryType { + + MYBATIS, + + MYBATIS_PLUS, + + JPA, + + JDBC, + + NOSQL, + + REDIS, + + MONGODB, + + ELASTICSEARCH, + + AUTO +} diff --git a/structure-infra-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/structure-infra-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..913bb9f --- /dev/null +++ b/structure-infra-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +cn.structure.infra.configuration.AutoEventConfiguration +cn.structure.infra.configuration.AutoRepositoryConfiguration +cn.structure.infra.lowcode.configuration.LowCodeAutoConfiguration \ No newline at end of file