1
0

refactor(all): 调整模块依赖,划分代码范围

This commit is contained in:
2026-01-21 10:33:08 +08:00
parent c839dfc4e3
commit e6a48d8e88
75 changed files with 365 additions and 354 deletions

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lanyuanxiaoyao</groupId>
<artifactId>spring-boot-service-template</artifactId>
<version>1.1.0-SNAPSHOT</version>
</parent>
<artifactId>spring-boot-service-template-database-common</artifactId>
<dependencies>
<dependency>
<groupId>com.lanyuanxiaoyao</groupId>
<artifactId>spring-boot-service-template-common</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.github.gavlyukovskiy</groupId>
<artifactId>p6spy-spring-boot-starter</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,68 @@
package com.lanyuanxiaoyao.service.template.database.common.controller;
import com.lanyuanxiaoyao.service.template.database.common.entity.GlobalResponse;
import com.lanyuanxiaoyao.service.template.database.common.entity.Query;
/**
* 查询控制器接口,用于定义统一的查询实体详情和列表的接口规范
* <p>
* 该接口提供了标准的查询功能,支持条件查询、分页查询和详情查询。
* 所有实现类应当遵循统一的请求响应格式。
* </p>
*
* <h3>查询条件说明</h3>
* <ul>
* <li><b>空值条件:</b> nullEqual、notNullEqual、empty、notEmpty</li>
* <li><b>相等条件:</b> equal、notEqual</li>
* <li><b>模糊匹配:</b> like、notLike、contain、notContain</li>
* <li><b>前后缀匹配:</b> startWith、notStartWith、endWith、notEndWith</li>
* <li><b>范围条件:</b> great、less、greatEqual、lessEqual</li>
* <li><b>集合条件:</b> inside、notInside</li>
* <li><b>区间条件:</b> between、notBetween</li>
* </ul>
*
* @param <LIST_ITEM> 列表查询结果的实体类型
* @param <DETAIL_ITEM> 详情查询结果的实体类型
*/
public interface QueryController<LIST_ITEM, DETAIL_ITEM> {
String LIST = "/list";
String DETAIL = "/detail/{id}";
/**
* 获取所有实体列表
* <p>
* 查询所有记录,不带任何过滤条件,返回分页格式的数据。
* 适用于获取全量数据的场景。
* </p>
*
* @return 返回包含实体列表的响应对象,格式:{status: 0, message: "OK", data: {items: [...], total: total}}
* @throws Exception 查询过程中可能抛出的异常
*/
GlobalResponse<GlobalResponse.ListItem<LIST_ITEM>> list() throws Exception;
/**
* 根据查询条件获取实体列表
* <p>
* 支持复杂的查询条件、排序和分页,返回符合条件的数据。
* 查询条件包括相等、模糊匹配、范围查询、集合查询等。
* </p>
*
* @param query 查询条件对象,包含过滤条件、排序规则和分页信息
* @return 返回符合条件的实体列表响应对象,格式:{status: 0, message: "OK", data: {items: [...], total: total}}
* @throws Exception 查询过程中可能抛出的异常
*/
GlobalResponse<GlobalResponse.ListItem<LIST_ITEM>> list(Query query) throws Exception;
/**
* 根据ID获取实体详情
* <p>
* 根据主键ID查询单条记录的详细信息。
* 适用于详情页面展示或数据编辑的场景。
* </p>
*
* @param id 实体主键ID
* @return 返回实体详情响应对象,格式:{status: 0, message: "OK", data: {item: 详情数据}}
* @throws Exception 查询过程中可能抛出的异常
*/
GlobalResponse<DETAIL_ITEM> detail(Long id) throws Exception;
}

View File

@@ -0,0 +1,27 @@
package com.lanyuanxiaoyao.service.template.database.common.controller;
import com.lanyuanxiaoyao.service.template.database.common.entity.GlobalResponse;
/**
* 删除控制器接口,用于定义统一的删除实体对象的接口规范
* <p>
* 该接口提供了标准的删除功能通过主键ID删除单条记录。
* 所有实现类应当遵循统一的请求响应格式。
* </p>
*/
public interface RemoveController {
String REMOVE = "/remove/{id}";
/**
* 根据ID删除实体对象
* <p>
* 根据主键ID删除指定的记录执行成功后返回操作结果。
* 适用于单条记录删除的场景。
* </p>
*
* @param id 需要删除的实体主键ID
* @return 返回删除结果响应对象,格式:{status: 0, message: "OK", data: null}
* @throws Exception 删除过程中可能抛出的异常
*/
GlobalResponse<Object> remove(Long id) throws Exception;
}

View File

@@ -0,0 +1,29 @@
package com.lanyuanxiaoyao.service.template.database.common.controller;
import com.lanyuanxiaoyao.service.template.database.common.entity.GlobalResponse;
/**
* 保存控制器接口,用于定义统一的保存实体对象的接口规范
* <p>
* 该接口提供了标准的保存功能,支持新增和更新操作。
* 所有实现类应当遵循统一的请求响应格式。
* </p>
*
* @param <SAVE_ITEM> 保存操作的实体类型
*/
public interface SaveController<SAVE_ITEM> {
String SAVE = "/save";
/**
* 保存实体对象
* <p>
* 保存或更新实体对象,根据业务逻辑判断是新增还是更新操作。
* 返回保存后的实体ID便于前端获取操作结果。
* </p>
*
* @param item 需要保存的实体对象,包含完整的字段信息
* @return 返回保存后的实体ID响应对象格式{status: 0, message: "OK", data: 实体ID}
* @throws Exception 保存过程中可能抛出的异常
*/
GlobalResponse<Long> save(SAVE_ITEM item) throws Exception;
}

View File

@@ -0,0 +1,4 @@
package com.lanyuanxiaoyao.service.template.database.common.controller;
public interface SimpleController<SAVE_ITEM, LIST_ITEM, DETAIL_ITEM> extends SaveController<SAVE_ITEM>, QueryController<LIST_ITEM, DETAIL_ITEM>, RemoveController {
}

View File

@@ -0,0 +1,359 @@
package com.lanyuanxiaoyao.service.template.database.common.entity;
import java.util.List;
import java.util.Map;
/**
* 全局统一API响应封装类
* <p>
* 该类用于统一封装RESTful API接口的响应结果提供标准化的响应格式。
* 通过状态码、消息和数据三个字段,清晰地表达请求的处理结果,便于前端统一处理。
* </p>
*
* <h3>设计特点</h3>
* <ul>
* <li>使用Java Record实现不可变线程安全</li>
* <li>泛型设计,支持任意类型的数据封装</li>
* <li>提供丰富的静态工厂方法,简化响应对象创建</li>
* <li>支持分页查询、详情查询等常见场景</li>
* </ul>
*
* <h3>响应格式示例</h3>
* <p><b>成功响应(无数据):</b></p>
* <pre>
* {
* "status": 0,
* "message": "OK",
* "data": null
* }
* </pre>
*
* <p><b>成功响应(带数据):</b></p>
* <pre>
* {
* "status": 0,
* "message": "操作成功",
* "data": {
* "id": 1,
* "name": "示例数据"
* }
* }
* </pre>
*
* <p><b>分页列表响应:</b></p>
* <pre>
* {
* "status": 0,
* "message": "OK",
* "data": {
* "items": [
* {"id": 1, "name": "数据1"},
* {"id": 2, "name": "数据2"}
* ],
* "total": 100
* }
* }
* </pre>
*
* <p><b>错误响应:</b></p>
* <pre>
* {
* "status": 500,
* "message": "系统异常,请稍后重试",
* "data": null
* }
* </pre>
*
* <h3>使用场景</h3>
* <ul>
* <li><b>RESTful API:</b> 作为所有API接口的标准响应格式</li>
* <li><b>前后端分离:</b> 前端可统一处理成功/失败逻辑</li>
* <li><b>微服务架构:</b> 服务间调用的标准化响应</li>
* <li><b>移动端接口:</b> 移动端APP的统一数据格式</li>
* </ul>
*
* @param <T> 响应数据的类型可以是任意对象、集合、Map或包装类
* @param status 响应状态码
* <ul>
* <li>0 - 成功</li>
* <li>500 - 服务器错误</li>
* <li>其他 - 业务自定义状态码</li>
* </ul>
* @param message 响应消息,对状态码的简短描述,如"OK"、"ERROR"或具体的错误信息
* @param data 响应数据具体的业务数据可以是任意类型。对于列表查询通常封装为包含items和total的Map对于详情查询直接返回对象或Map
*
* @see #responseSuccess()
* @see #responseError()
* @see #responseListData(Iterable, Long)
* @see #responseDetailData(Object)
*/
public record GlobalResponse<T>(Integer status, String message, T data) {
/**
* 成功状态码 - 表示请求处理成功
*/
private static final int SUCCESS_STATUS = 0;
/**
* 错误状态码 - 表示服务器内部错误或业务异常
*/
private static final int ERROR_STATUS = 500;
/**
* 成功默认消息 - 用于通用成功响应
*/
private static final String SUCCESS_MESSAGE = "OK";
/**
* 错误默认消息 - 用于通用错误响应
*/
private static final String ERROR_MESSAGE = "ERROR";
/**
* 返回默认错误响应
* <p>
* 使用默认错误消息"ERROR"状态码500数据为null。
* 适用于无法确定具体错误原因的通用异常场景。
* </p>
*
* @return 错误响应对象,格式:{status: 500, message: "ERROR", data: null}
*
* @see #responseError(String)
* @see #responseSuccess()
*/
public static GlobalResponse<Object> responseError() {
return responseError(ERROR_MESSAGE);
}
/**
* 返回指定错误消息的响应
* <p>
* 使用指定的错误消息状态码500数据为null。
* 适用于需要向客户端传递具体错误信息的场景。
* </p>
*
* @param message 错误消息内容,建议描述具体错误原因,便于前端展示和问题定位
* @return 错误响应对象,格式:{status: 500, message: "自定义错误信息", data: null}
*
* @see #responseError()
* @see #responseSuccess(String)
*/
public static GlobalResponse<Object> responseError(String message) {
return new GlobalResponse<>(ERROR_STATUS, message, null);
}
/**
* 返回默认成功响应
* <p>
* 使用默认成功消息"OK"状态码0数据为null。
* 适用于操作成功但不需要返回数据的场景。
* </p>
*
* @return 成功响应对象,格式:{status: 0, message: "OK", data: null}
*
* @see #responseSuccess(String)
* @see #responseSuccess(Object)
* @see #responseError()
*/
public static GlobalResponse<Object> responseSuccess() {
return responseSuccess(SUCCESS_MESSAGE);
}
/**
* 返回指定成功消息的响应
* <p>
* 使用指定的成功消息状态码0数据为null。
* 适用于需要向客户端返回自定义成功提示的场景。
* </p>
*
* @param message 成功消息内容,建议描述具体操作结果,便于用户理解
* @return 成功响应对象,格式:{status: 0, message: "自定义成功信息", data: null}
*
* @see #responseSuccess()
* @see #responseSuccess(Object)
* @see #responseSuccess(String, Object)
*/
public static GlobalResponse<Object> responseSuccess(String message) {
return responseSuccess(message, null);
}
/**
* 返回包含数据的成功响应
* <p>
* 使用默认成功消息"OK"状态码0包含指定数据。
* 适用于需要返回数据但不需要自定义消息的场景。
* </p>
*
* @param <E> 数据类型可以是任意Java对象
* @param data 业务数据可以是实体对象、Map、集合等
* @return 成功响应对象,格式:{status: 0, message: "OK", data: 业务数据}
*
* @see #responseSuccess(String, Object)
* @see #responseListData(Iterable, Long)
* @see #responseDetailData(Object)
*/
public static <E> GlobalResponse<E> responseSuccess(E data) {
return responseSuccess(SUCCESS_MESSAGE, data);
}
/**
* 返回包含指定消息和数据的成功响应
* <p>
* 使用指定的成功消息状态码0包含指定数据。
* 这是最完整的方法,适用于需要同时自定义消息和返回数据的场景。
* </p>
*
* @param <E> 数据类型
* @param message 成功消息内容,描述具体操作结果
* @param data 业务数据,可以是任意类型
* @return 成功响应对象,格式:{status: 0, message: "自定义消息", data: 业务数据}
*
* @see #responseSuccess()
* @see #responseSuccess(Object)
* @see #responseSuccess(String)
*/
public static <E> GlobalResponse<E> responseSuccess(String message, E data) {
return new GlobalResponse<>(SUCCESS_STATUS, message, data);
}
/**
* 返回Map类型数据的成功响应
* <p>
* 适用于需要返回结构化数据的场景,如分页查询结果、统计信息等。
* 内部使用responseSuccess方法封装。
* </p>
*
* @param data Map格式的业务数据键为String值为任意对象
* @return 成功响应对象,格式:{status: 0, message: "OK", data: Map数据}
*
* @see #responseMapData(String, Object)
* @see #responseSuccess(Object)
*/
public static GlobalResponse<Map<String, Object>> responseMapData(Map<String, Object> data) {
return responseSuccess(data);
}
/**
* 返回单个键值对的成功响应
* <p>
* 将单个键值对封装为Map后返回适用于返回单个配置项或简单结果的场景。
* </p>
*
* @param key 数据键名不能为null
* @param value 数据值,可以是任意对象
* @return 成功响应对象,格式:{status: 0, message: "OK", data: {key: value}}
*
* @see #responseMapData(Map)
* @see #responseSuccess(Object)
*/
public static GlobalResponse<Map<String, Object>> responseMapData(String key, Object value) {
return responseMapData(Map.of(key, value));
}
/**
* 返回空列表的成功响应
* <p>
* 适用于查询结果为空的场景返回空列表和总数为0。
* </p>
*
* @param <T> 数据项类型
* @return 成功响应对象,格式:{status: 0, message: "OK", data: {items: [], total: 0}}
*
* @see #responseListData(Iterable, Long)
* @see #responseListData(Iterable, Integer)
*/
public static <T> GlobalResponse<ListItem<T>> responseListData() {
return responseListData(List.of(), 0);
}
/**
* 返回CRUD列表数据的成功响应Integer类型总数
* <p>
* 适用于分页查询,将数据列表和总数封装为标准格式。
* 自动将Integer类型的总数转换为Long类型。
* </p>
*
* @param <T> 数据项类型
* @param data 数据列表可以是List、Set等Iterable实现
* @param total 总记录数Integer类型
* @return 成功响应对象,格式:{status: 0, message: "OK", data: {items: [...], total: total}}
*
* @see #responseListData(Iterable, Long)
* @see #responseListData()
* @see #responseSuccess(Object)
*/
public static <T> GlobalResponse<ListItem<T>> responseListData(Iterable<T> data, Integer total) {
return responseListData(data, total.longValue());
}
/**
* 返回CRUD列表数据的成功响应Long类型总数
* <p>
* 适用于分页查询,将数据列表和总数封装为标准格式。
* 支持大数据量场景使用Long类型避免整数溢出。
* </p>
*
* @param <T> 数据项类型
* @param data 数据列表可以是List、Set等Iterable实现
* @param total 总记录数Long类型支持大数据量
* @return 成功响应对象,格式:{status: 0, message: "OK", data: {items: [...], total: total}}
*
* @see #responseListData(Iterable, Integer)
* @see #responseListData()
* @see ListItem
* @see #responseSuccess(Object)
*/
public static <T> GlobalResponse<ListItem<T>> responseListData(Iterable<T> data, Long total) {
return responseSuccess(new ListItem<>(data, total));
}
/**
* 返回详情数据的成功响应
* <p>
* 适用于详情查询,将单条记录封装为标准格式。
* 便于前端统一处理详情数据的展示。
* </p>
*
* @param <T> 数据类型
* @param data 详情数据可以是实体对象、Map等
* @return 成功响应对象,格式:{status: 0, message: "OK", data: {item: 详情数据}}
*
* @see #responseSuccess(Object)
* @see DetailItem
*/
public static <T> GlobalResponse<DetailItem<T>> responseDetailData(T data) {
return responseSuccess(new DetailItem<>(data));
}
/**
* 列表数据封装类
* <p>
* 用于封装分页查询的结果,包含数据列表和总记录数。
* 便于前端进行分页控件的渲染和数据展示。
* </p>
*
* @param <T> 数据项类型
* @param items 数据列表,包含当前页的所有记录
* @param total 总记录数,用于计算总页数和显示分页信息
*
* @see #responseListData(Iterable, Long)
*/
public record ListItem<T>(Iterable<T> items, Long total) {
}
/**
* 详情数据封装类
* <p>
* 用于封装单条记录的查询结果,提供统一的详情数据结构。
* 便于前端统一处理详情页面的数据展示。
* </p>
*
* <p><b>注意:</b> item字段使用Object类型可以存储任意类型的详情数据。</p>
*
* @param <T> 数据类型(主要用于类型提示)
* @param item 单条记录数据可以是实体对象、Map、VO对象等
*
* @see #responseDetailData(Object)
*/
public record DetailItem<T>(T item) {
}
}

View File

@@ -0,0 +1,31 @@
package com.lanyuanxiaoyao.service.template.database.common.entity;
import java.util.List;
/**
* 分页数据封装类
* <p>
* 用于封装分页查询的结果,包含数据流和总记录数。
* 适用于需要流式处理大量数据的场景,同时提供总数用于分页计算。
* </p>
*
* <h3>使用场景</h3>
* <ul>
* <li>数据库分页查询结果封装</li>
* <li>大数据量流式处理</li>
* <li>分页控件的数据源</li>
* </ul>
*
* <h3>特点</h3>
* <ul>
* <li>使用Java Record实现不可变线程安全</li>
* <li>支持流式数据处理,内存效率高</li>
* <li>包含总记录数,便于分页计算</li>
* </ul>
*
* @param <ENTITY> 实体类型
* @param items 数据流,包含当前页的所有记录
* @param total 总记录数,用于计算总页数和显示分页信息
*/
public record Page<ENTITY>(List<ENTITY> items, long total) {
}

View File

@@ -0,0 +1,195 @@
package com.lanyuanxiaoyao.service.template.database.common.entity;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
/**
* 查询条件封装类,用于构建复杂的查询条件
* <p>
* 该类统一封装了查询条件、排序条件和分页条件,支持多种复杂的查询场景。
* 通过JSON格式传递查询参数后端自动解析并转换为数据库查询条件。
* </p>
*
* <p>前端传入的JSON格式示例:</p>
* <pre>
* {
* "query": {
* "equal": {
* "name": "张三"
* },
* "like": {
* "address": "%北京%"
* },
* "greatEqual": {
* "age": 18
* },
* "less": {
* "age": 60
* },
* "between": {
* "salary": {
* "start": 5000,
* "end": 10000
* }
* }
* },
* "sort": [
* {
* "column": "createTime",
* "direction": "DESC"
* }
* ],
* "page": {
* "index": 0,
* "size": 10
* }
* }
* </pre>
*
* <p>查询条件说明:</p>
* <ul>
* <li><b>nullEqual</b>: 字段值为null的条件列表</li>
* <li><b>notNullEqual</b>: 字段值不为null的条件列表</li>
* <li><b>empty</b>: 字段值为空的条件列表(如空字符串、空集合等)</li>
* <li><b>notEmpty</b>: 字段值不为空的条件列表</li>
* <li><b>equal</b>: 字段值相等的条件映射(字段名 -> 值)</li>
* <li><b>notEqual</b>: 字段值不相等的条件映射(字段名 -> 值)</li>
* <li><b>like</b>: 字段值模糊匹配的条件映射(字段名 -> 匹配值)</li>
* <li><b>notLike</b>: 字段值不模糊匹配的条件映射(字段名 -> 匹配值)</li>
* <li><b>contain</b>: 字段包含指定字符串的条件映射(字段名 -> 包含值)</li>
* <li><b>notContain</b>: 字段不包含指定字符串的条件映射(字段名 -> 不包含值)</li>
* <li><b>startWith</b>: 字段以指定字符串开头的条件映射(字段名 -> 开头值)</li>
* <li><b>notStartWith</b>: 字段不以指定字符串开头的条件映射(字段名 -> 不开头值)</li>
* <li><b>endWith</b>: 字段以指定字符串结尾的条件映射(字段名 -> 结尾值)</li>
* <li><b>notEndWith</b>: 字段不以指定字符串结尾的条件映射(字段名 -> 不结尾值)</li>
* <li><b>great</b>: 字段大于条件的映射(字段名 -> 值)</li>
* <li><b>less</b>: 字段小于条件的映射(字段名 -> 值)</li>
* <li><b>greatEqual</b>: 字段大于等于条件的映射(字段名 -> 值)</li>
* <li><b>lessEqual</b>: 字段小于等于条件的映射(字段名 -> 值)</li>
* <li><b>inside</b>: 字段值在指定范围内的条件映射(字段名 -> 值列表)</li>
* <li><b>notInside</b>: 字段值不在指定范围内的条件映射(字段名 -> 值列表)</li>
* <li><b>between</b>: 字段值在指定区间内的条件映射(字段名 -> 区间范围)</li>
* <li><b>notBetween</b>: 字段值不在指定区间内的条件映射(字段名 -> 区间范围)</li>
* </ul>
*
* @param query 查询条件对象,包含所有查询条件的封装
* @param sort 排序条件列表,支持多字段排序
* @param page 分页条件对象,指定页码和每页大小
*/
public record Query(
Queryable query,
List<Sortable> sort,
Pageable page
) {
/**
* 可查询条件类,封装各种查询条件
* <p>
* 该类包含了所有支持的查询条件类型,每个字段对应一种查询条件。
* 字段名即为JSON中的键名字段类型决定了查询条件的值类型。
* </p>
*
* @param nullEqual 字段值为null的条件列表列表中的每个元素都是一个字段名
* @param notNullEqual 字段值不为null的条件列表列表中的每个元素都是一个字段名
* @param empty 字段值为空的条件列表(如空字符串、空集合等),列表中的每个元素都是一个字段名
* @param notEmpty 字段值不为空的条件列表,列表中的每个元素都是一个字段名
* @param equal 字段值相等的条件映射,键为字段名,值为要相等的值
* @param notEqual 字段值不相等的条件映射,键为字段名,值为要不相等的值
* @param like 字段值模糊匹配的条件映射,键为字段名,值为模糊匹配的模式(支持%通配符)
* @param notLike 字段值不模糊匹配的条件映射,键为字段名,值为不匹配的模式
* @param contain 字段包含指定字符串的条件映射,键为字段名,值为要包含的字符串
* @param notContain 字段不包含指定字符串的条件映射,键为字段名,值为不包含的字符串
* @param startWith 字段以指定字符串开头的条件映射,键为字段名,值为开头字符串
* @param notStartWith 字段不以指定字符串开头的条件映射,键为字段名,值为不开头的字符串
* @param endWith 字段以指定字符串结尾的条件映射,键为字段名,值为结尾字符串
* @param notEndWith 字段不以指定字符串结尾的条件映射,键为字段名,值为不结尾的字符串
* @param great 字段大于条件的映射,键为字段名,值为比较的阈值
* @param less 字段小于条件的映射,键为字段名,值为比较的阈值
* @param greatEqual 字段大于等于条件的映射,键为字段名,值为比较的阈值
* @param lessEqual 字段小于等于条件的映射,键为字段名,值为比较的阈值
* @param inside 字段值在指定范围内的条件映射,键为字段名,值为允许的值列表
* @param notInside 字段值不在指定范围内的条件映射,键为字段名,值为不允许的值列表
* @param between 字段值在指定区间内的条件映射,键为字段名,值为区间范围对象
* @param notBetween 字段值不在指定区间内的条件映射,键为字段名,值为区间范围对象
*/
public record Queryable(
List<String> nullEqual,
List<String> notNullEqual,
List<String> empty,
List<String> notEmpty,
Map<String, ? extends Serializable> equal,
Map<String, ? extends Serializable> notEqual,
Map<String, String> like,
Map<String, String> notLike,
Map<String, String> contain,
Map<String, String> notContain,
Map<String, String> startWith,
Map<String, String> notStartWith,
Map<String, String> endWith,
Map<String, String> notEndWith,
Map<String, ? extends Serializable> great,
Map<String, ? extends Serializable> less,
Map<String, ? extends Serializable> greatEqual,
Map<String, ? extends Serializable> lessEqual,
Map<String, List<? extends Serializable>> inside,
Map<String, List<? extends Serializable>> notInside,
Map<String, Between> between,
Map<String, Between> notBetween
) {
/**
* 区间范围类,用于表示起始值和结束值
* <p>
* 主要用于 between 和 notBetween 查询条件,表示一个数值或时间的区间范围。
* </p>
*
* @param start 区间起始值(包含)
* @param end 区间结束值(包含)
*/
public record Between(
Object start,
Object end
) {
}
}
/**
* 排序条件类,用于指定排序字段和排序方向
*
* @param column 排序字段名,对应数据库表的列名或实体类的属性名
* @param direction 排序方向ASC表示升序DESC表示降序
*/
public record Sortable(
String column,
Direction direction
) {
/**
* 排序方向枚举
*/
public enum Direction {
/**
* 升序排列(从小到大)
*/
ASC,
/**
* 降序排列(从大到小)
*/
DESC,
}
}
/**
* 可分页条件类,用于指定分页参数
* <p>
* 页码从0开始计数即第一页的索引为0。
* </p>
*
* @param index 页码索引从0开始0表示第一页
* @param size 每页大小,即每页显示的记录数
*/
public record Pageable(
Integer index,
Integer size
) {
}
}

View File

@@ -0,0 +1,7 @@
package com.lanyuanxiaoyao.service.template.database.common.exception;
public class IdNotFoundException extends RuntimeException {
public IdNotFoundException(Long id) {
super("ID为 %d 的资源不存在".formatted(id));
}
}

View File

@@ -0,0 +1,7 @@
package com.lanyuanxiaoyao.service.template.database.common.exception;
public class NotCollectionException extends RuntimeException {
public NotCollectionException(String variable) {
super("变量 %s 不是集合".formatted(variable));
}
}

View File

@@ -0,0 +1,7 @@
package com.lanyuanxiaoyao.service.template.database.common.exception;
public class NotComparableException extends RuntimeException {
public NotComparableException(String variable) {
super("变量 %s 不能比较".formatted(variable));
}
}

View File

@@ -0,0 +1,7 @@
package com.lanyuanxiaoyao.service.template.database.common.exception;
public class NotStringException extends RuntimeException {
public NotStringException(String variable) {
super("变量 %s 不是字符串".formatted(variable));
}
}

View File

@@ -0,0 +1,68 @@
package com.lanyuanxiaoyao.service.template.database.common.helper;
import java.time.Instant;
public class SnowflakeHelper {
/**
* 起始的时间戳
*/
private final static long START_TIMESTAMP = 1;
/**
* 序列号占用的位数
*/
private final static long SEQUENCE_BIT = 11;
/**
* 序列号最大值
*/
private final static long MAX_SEQUENCE_BIT = ~(-1 << SEQUENCE_BIT);
/**
* 时间戳值向左位移
*/
private final static long TIMESTAMP_OFFSET = SEQUENCE_BIT;
/**
* 序列号
*/
private static long sequence = 0;
/**
* 上一次时间戳
*/
private static long lastTimestamp = -1;
public static synchronized long next() {
long currentTimestamp = nowTimestamp();
if (currentTimestamp < lastTimestamp) {
throw new RuntimeException("Clock have moved backwards.");
}
if (currentTimestamp == lastTimestamp) {
// 相同毫秒内, 序列号自增
sequence = (sequence + 1) & MAX_SEQUENCE_BIT;
// 同一毫秒的序列数已经达到最大
if (sequence == 0) {
currentTimestamp = nextTimestamp();
}
} else {
// 不同毫秒内, 序列号置为0
sequence = 0;
}
lastTimestamp = currentTimestamp;
return (currentTimestamp - START_TIMESTAMP) << TIMESTAMP_OFFSET | sequence;
}
private static long nextTimestamp() {
long milli = nowTimestamp();
while (milli <= lastTimestamp) {
milli = nowTimestamp();
}
return milli;
}
private static long nowTimestamp() {
return Instant.now().toEpochMilli();
}
}

View File

@@ -0,0 +1,128 @@
package com.lanyuanxiaoyao.service.template.database.common.service;
import com.lanyuanxiaoyao.service.template.common.helper.ObjectHelper;
import com.lanyuanxiaoyao.service.template.database.common.entity.Query;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class QueryParser<O> {
private final Query.Queryable queryable;
private final O container;
protected abstract void nullEqual(Query.Queryable queryable, O container);
protected abstract void notNullEqual(Query.Queryable queryable, O container);
protected abstract void empty(Query.Queryable queryable, O container);
protected abstract void notEmpty(Query.Queryable queryable, O container);
protected abstract void equal(Query.Queryable queryable, O container);
protected abstract void notEqual(Query.Queryable queryable, O container);
protected abstract void like(Query.Queryable queryable, O container);
protected abstract void notLike(Query.Queryable queryable, O container);
protected abstract void contain(Query.Queryable queryable, O container);
protected abstract void notContain(Query.Queryable queryable, O container);
protected abstract void startWith(Query.Queryable queryable, O container);
protected abstract void notStartWith(Query.Queryable queryable, O container);
protected abstract void endWith(Query.Queryable queryable, O container);
protected abstract void notEndWith(Query.Queryable queryable, O container);
protected abstract void great(Query.Queryable queryable, O container);
protected abstract void less(Query.Queryable queryable, O container);
protected abstract void greatEqual(Query.Queryable queryable, O container);
protected abstract void lessEqual(Query.Queryable queryable, O container);
protected abstract void inside(Query.Queryable queryable, O container);
protected abstract void notInside(Query.Queryable queryable, O container);
protected abstract void between(Query.Queryable queryable, O container);
protected abstract void notBetween(Query.Queryable queryable, O container);
public void build() {
if (ObjectHelper.isNull(queryable)) {
return;
}
if (ObjectHelper.isNotEmpty(queryable.nullEqual())) {
nullEqual(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.notNullEqual())) {
notNullEqual(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.empty())) {
empty(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.notEmpty())) {
notEmpty(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.equal())) {
equal(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.notEqual())) {
notEqual(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.like())) {
like(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.notLike())) {
notLike(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.contain())) {
contain(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.notContain())) {
notContain(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.startWith())) {
startWith(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.notStartWith())) {
notStartWith(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.endWith())) {
endWith(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.notEndWith())) {
notEndWith(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.great())) {
great(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.less())) {
less(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.greatEqual())) {
greatEqual(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.lessEqual())) {
lessEqual(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.inside())) {
inside(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.notInside())) {
notInside(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.between())) {
between(queryable, container);
}
if (ObjectHelper.isNotEmpty(queryable.notBetween())) {
notBetween(queryable, container);
}
}
}

View File

@@ -0,0 +1,92 @@
package com.lanyuanxiaoyao.service.template.database.common.service;
import com.lanyuanxiaoyao.service.template.database.common.entity.Page;
import com.lanyuanxiaoyao.service.template.database.common.entity.Query;
import java.util.List;
import java.util.Set;
/**
* 查询服务接口,用于定义统一的查询实体详情和列表的服务规范
* <p>
* 该接口提供了标准的查询功能,支持详情查询、列表查询、分页查询和统计查询。
* 所有实现类应当遵循统一的查询逻辑和异常处理规范。
* </p>
*
* @param <ENTITY> 实体类型
*/
public interface QueryService<ENTITY> {
/**
* 根据ID获取实体详情
* <p>
* 查询单条记录的详细信息如果记录不存在返回null。
* </p>
*
* @param id 实体主键ID
* @return 实体详情如果不存在则返回null
* @throws Exception 查询过程中可能抛出的异常
*/
ENTITY detail(Long id) throws Exception;
/**
* 根据ID获取实体详情如果不存在则抛出异常
* <p>
* 查询单条记录的详细信息,如果记录不存在则抛出异常。
* 适用于需要确保记录存在的场景。
* </p>
*
* @param id 实体主键ID
* @return 实体详情
* @throws Exception 当记录不存在或查询失败时抛出异常
*/
ENTITY detailOrThrow(Long id) throws Exception;
/**
* 获取实体总数
* <p>
* 统计所有记录的数量,不带任何过滤条件。
* </p>
*
* @return 实体总数
* @throws Exception 查询过程中可能抛出的异常
*/
Long count() throws Exception;
/**
* 获取所有实体列表
* <p>
* 查询所有记录,不带任何过滤条件,返回完整列表。
* 适用于数据量较小或需要全量数据的场景。
* </p>
*
* @return 实体列表
* @throws Exception 查询过程中可能抛出的异常
*/
List<ENTITY> list() throws Exception;
/**
* 根据ID集合获取实体列表
* <p>
* 批量查询指定ID的记录返回对应的实体列表。
* 适用于需要批量获取特定记录的场景。
* </p>
*
* @param ids 实体ID集合
* @return 实体列表包含集合中ID对应的记录
* @throws Exception 查询过程中可能抛出的异常
*/
List<ENTITY> list(Set<Long> ids) throws Exception;
/**
* 根据查询条件获取分页实体列表
* <p>
* 支持复杂的查询条件、排序和分页,返回符合条件的数据。
* 这是最完整的查询方法,适用于大多数业务场景。
* </p>
*
* @param query 查询条件对象,包含过滤条件、排序规则和分页信息
* @return 分页实体列表,包含数据流和总记录数
* @throws Exception 查询过程中可能抛出的异常
*/
Page<ENTITY> list(Query query) throws Exception;
}

View File

@@ -0,0 +1,39 @@
package com.lanyuanxiaoyao.service.template.database.common.service;
import java.util.Set;
/**
* 删除服务接口,用于定义统一的删除实体对象的服务规范
* <p>
* 该接口提供了标准的删除功能,支持单条记录删除和批量删除。
* 所有实现类应当遵循统一的删除逻辑和异常处理规范。
* </p>
*
* @param <ENTITY> 实体类型
*/
public interface RemoveService<ENTITY> {
/**
* 根据ID删除实体对象
* <p>
* 删除指定ID的单条记录执行成功后无返回值。
* 适用于单条记录删除的场景。
* </p>
*
* @param id 需要删除的实体主键ID
* @throws Exception 删除过程中可能抛出的异常
*/
void remove(Long id) throws Exception;
/**
* 批量删除实体对象
* <p>
* 删除指定ID集合的多条记录执行成功后无返回值。
* 适用于批量删除的场景,提高删除效率。
* </p>
*
* @param ids 需要删除的实体ID集合
* @throws Exception 删除过程中可能抛出的异常
*/
void remove(Set<Long> ids) throws Exception;
}

View File

@@ -0,0 +1,38 @@
package com.lanyuanxiaoyao.service.template.database.common.service;
/**
* 保存服务接口,用于定义统一的保存实体对象的服务规范
* <p>
* 该接口提供了标准的保存功能,支持单条记录保存和批量保存。
* 所有实现类应当遵循统一的保存逻辑和异常处理规范。
* </p>
*
* @param <ENTITY> 实体类型
*/
public interface SaveService<ENTITY> {
/**
* 保存实体对象
* <p>
* 保存或更新单条实体记录,根据业务逻辑判断是新增还是更新操作。
* 返回保存后的实体ID便于后续操作。
* </p>
*
* @param entity 需要保存的实体对象,包含完整的字段信息
* @return 保存后的实体主键ID
* @throws Exception 保存过程中可能抛出的异常
*/
Long save(ENTITY entity) throws Exception;
/**
* 批量保存实体对象
* <p>
* 批量保存或更新多条实体记录,提高数据处理效率。
* 适用于批量数据导入或同步的场景。
* </p>
*
* @param entities 需要保存的实体对象集合
* @throws Exception 保存过程中可能抛出的异常
*/
void save(Iterable<ENTITY> entities) throws Exception;
}

View File

@@ -0,0 +1,4 @@
package com.lanyuanxiaoyao.service.template.database.common.service;
public interface SimpleService<ENTITY> extends SaveService<ENTITY>, QueryService<ENTITY>, RemoveService<ENTITY> {
}

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lanyuanxiaoyao</groupId>
<artifactId>spring-boot-service-template</artifactId>
<version>1.1.0-SNAPSHOT</version>
</parent>
<artifactId>spring-boot-service-template-database-eq</artifactId>
<dependencies>
<dependency>
<groupId>com.lanyuanxiaoyao</groupId>
<artifactId>spring-boot-service-template-database-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.easy-query</groupId>
<artifactId>sql-springboot4-starter</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
<path>
<groupId>com.easy-query</groupId>
<artifactId>sql-processor</artifactId>
<version>${easy-query.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,209 @@
package com.lanyuanxiaoyao.service.template.eq.controller;
import com.easy.query.core.proxy.AbstractProxyEntity;
import com.easy.query.core.proxy.ProxyEntityAvailable;
import com.lanyuanxiaoyao.service.template.common.helper.ObjectHelper;
import com.lanyuanxiaoyao.service.template.database.common.controller.SimpleController;
import com.lanyuanxiaoyao.service.template.database.common.entity.GlobalResponse;
import com.lanyuanxiaoyao.service.template.database.common.entity.Query;
import com.lanyuanxiaoyao.service.template.eq.entity.SimpleEntity;
import com.lanyuanxiaoyao.service.template.eq.service.SimpleServiceSupport;
import java.util.function.Function;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* 简单控制器支持类提供基础的CRUD操作实现
* <p>
* 该类实现了基本的增删改查功能,通过泛型支持不同类型的数据转换。
* 子类需要实现对应的Mapper函数来完成实体类与传输对象之间的转换。
* </p>
*
* <h3>设计特点</h3>
* <ul>
* <li>泛型设计,支持任意实体类型和数据转换</li>
* <li>统一的异常处理和事务管理</li>
* <li>支持条件查询、分页查询和详情查询</li>
* <li>提供抽象的Mapper方法便于子类实现数据转换逻辑</li>
* </ul>
*
* <h3>使用说明</h3>
* <p>子类需要实现以下抽象方法:</p>
* <ul>
* <li>saveItemMapper(): 保存项到实体的转换函数</li>
* <li>listItemMapper(): 实体到列表项的转换函数</li>
* <li>detailItemMapper(): 实体到详情项的转换函数</li>
* </ul>
*
* @param <ENTITY> 实体类型必须继承SimpleEntity
* @param <SAVE_ITEM> 保存项类型
* @param <LIST_ITEM> 列表项类型
* @param <DETAIL_ITEM> 详情项类型
*/
@Slf4j
@RequiredArgsConstructor
public abstract class SimpleControllerSupport<ENTITY extends SimpleEntity & ProxyEntityAvailable<ENTITY, PROXY>, PROXY extends AbstractProxyEntity<PROXY, ENTITY>, SAVE_ITEM, LIST_ITEM, DETAIL_ITEM> implements SimpleController<SAVE_ITEM, LIST_ITEM, DETAIL_ITEM> {
protected final SimpleServiceSupport<ENTITY, PROXY> service;
/**
* 保存实体对象
* <p>
* 将保存项转换为实体对象后保存返回保存后的实体ID。
* 支持新增和更新操作,通过事务保证数据一致性。
* </p>
*
* @param item 需要保存的项
* @return 返回保存后的实体ID响应对象格式{status: 0, message: "OK", data: 实体ID}
* @throws Exception 保存过程中可能抛出的异常
*/
@Transactional(rollbackFor = Throwable.class)
@PostMapping(SAVE)
@Override
public GlobalResponse<Long> save(@RequestBody SAVE_ITEM item) throws Exception {
var mapper = saveItemMapper();
return GlobalResponse.responseSuccess(service.save(mapper.apply(item)));
}
/**
* 获取所有实体列表
* <p>
* 查询所有记录,不带任何过滤条件,返回分页格式的数据。
* 将实体对象转换为列表项对象后返回。
* </p>
*
* @return 返回实体列表响应对象,格式:{status: 0, message: "OK", data: {items: [...], total: total}}
* @throws Exception 查询过程中可能抛出的异常
*/
@Transactional(readOnly = true)
@GetMapping(LIST)
@Override
public GlobalResponse<GlobalResponse.ListItem<LIST_ITEM>> list() throws Exception {
var mapper = listItemMapper();
var result = service.list();
return GlobalResponse.responseListData(
result
.stream()
.map(entity -> {
try {
return mapper.apply(entity);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList(),
result.size()
);
}
/**
* 根据查询条件获取实体列表
* <p>
* 支持复杂的查询条件、排序和分页,返回符合条件的数据。
* 将实体对象转换为列表项对象后返回。
* </p>
*
* @param query 查询条件对象,包含过滤条件、排序规则和分页信息
* @return 返回符合条件的实体列表响应对象,格式:{status: 0, message: "OK", data: {items: [...], total: total}}
* @throws Exception 查询过程中可能抛出的异常
*/
@Transactional(readOnly = true)
@PostMapping(LIST)
@Override
public GlobalResponse<GlobalResponse.ListItem<LIST_ITEM>> list(@RequestBody Query query) throws Exception {
if (ObjectHelper.isNull(query)) {
return GlobalResponse.responseListData();
}
var mapper = listItemMapper();
var result = service.list(query);
return GlobalResponse.responseListData(
result.items()
.stream()
.map(entity -> {
try {
return mapper.apply(entity);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList(),
result.total()
);
}
/**
* 根据ID获取实体详情
* <p>
* 根据主键ID查询单条记录的详细信息转换为详情项对象后返回。
* 如果记录不存在则抛出异常。
* </p>
*
* @param id 实体主键ID
* @return 返回实体详情响应对象,格式:{status: 0, message: "OK", data: 详情数据}
* @throws Exception 查询过程中可能抛出的异常
*/
@Transactional(readOnly = true)
@GetMapping(DETAIL)
@Override
public GlobalResponse<DETAIL_ITEM> detail(@PathVariable("id") Long id) throws Exception {
var mapper = detailItemMapper();
return GlobalResponse.responseSuccess(mapper.apply(service.detailOrThrow(id)));
}
/**
* 根据ID删除实体对象
* <p>
* 根据主键ID删除指定的记录执行成功后返回成功响应。
* 通过事务保证删除操作的一致性。
* </p>
*
* @param id 需要删除的实体主键ID
* @return 返回删除结果响应对象,格式:{status: 0, message: "OK", data: null}
* @throws Exception 删除过程中可能抛出的异常
*/
@Transactional(rollbackFor = Throwable.class)
@GetMapping(REMOVE)
@Override
public GlobalResponse<Object> remove(@PathVariable("id") Long id) throws Exception {
service.remove(id);
return GlobalResponse.responseSuccess();
}
/**
* 保存项映射器,将保存项转换为实体对象
* <p>
* 子类需要实现此方法,定义保存项到实体的转换逻辑。
* </p>
*
* @return Function<SAVE_ITEM, ENTITY> 保存项到实体的转换函数
*/
protected abstract Function<SAVE_ITEM, ENTITY> saveItemMapper();
/**
* 列表项映射器,将实体对象转换为列表项
* <p>
* 子类需要实现此方法,定义实体到列表项的转换逻辑。
* </p>
*
* @return Function<ENTITY, LIST_ITEM> 实体到列表项的转换函数
*/
protected abstract Function<ENTITY, LIST_ITEM> listItemMapper();
/**
* 详情项映射器,将实体对象转换为详情项
* <p>
* 子类需要实现此方法,定义实体到详情项的转换逻辑。
* </p>
*
* @return Function<ENTITY, DETAIL_ITEM> 实体到详情项的转换函数
*/
protected abstract Function<ENTITY, DETAIL_ITEM> detailItemMapper();
public interface Mapper<S, T> {
T map(S source) throws Exception;
}
}

View File

@@ -0,0 +1,16 @@
package com.lanyuanxiaoyao.service.template.eq.entity;
import com.easy.query.core.annotation.Column;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
@Getter
@Setter
@ToString
@FieldNameConstants
public class IdOnlyEntity {
@Column(primaryKey = true, primaryKeyGenerator = SnowflakeIdGenerator.class)
private Long id;
}

View File

@@ -0,0 +1,16 @@
package com.lanyuanxiaoyao.service.template.eq.entity;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
@Getter
@Setter
@ToString(callSuper = true)
@FieldNameConstants
public class SimpleEntity extends IdOnlyEntity {
private LocalDateTime createdTime;
private LocalDateTime modifiedTime;
}

View File

@@ -0,0 +1,14 @@
package com.lanyuanxiaoyao.service.template.eq.entity;
import com.easy.query.core.basic.extension.generated.PrimaryKeyGenerator;
import com.lanyuanxiaoyao.service.template.database.common.helper.SnowflakeHelper;
import java.io.Serializable;
import org.springframework.stereotype.Component;
@Component
public class SnowflakeIdGenerator implements PrimaryKeyGenerator {
@Override
public Serializable getPrimaryKey() {
return SnowflakeHelper.next();
}
}

View File

@@ -0,0 +1,274 @@
package com.lanyuanxiaoyao.service.template.eq.service;
import com.easy.query.api.proxy.client.EasyEntityQuery;
import com.easy.query.core.enums.SQLExecuteStrategyEnum;
import com.easy.query.core.proxy.AbstractProxyEntity;
import com.easy.query.core.proxy.ProxyEntityAvailable;
import com.lanyuanxiaoyao.service.template.common.helper.ObjectHelper;
import com.lanyuanxiaoyao.service.template.database.common.entity.Page;
import com.lanyuanxiaoyao.service.template.database.common.entity.Query;
import com.lanyuanxiaoyao.service.template.database.common.exception.IdNotFoundException;
import com.lanyuanxiaoyao.service.template.database.common.service.QueryParser;
import com.lanyuanxiaoyao.service.template.database.common.service.SimpleService;
import com.lanyuanxiaoyao.service.template.eq.entity.SimpleEntity;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.mapstruct.Named;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
public abstract class SimpleServiceSupport<ENTITY extends SimpleEntity & ProxyEntityAvailable<ENTITY, PROXY>, PROXY extends AbstractProxyEntity<PROXY, ENTITY>> implements SimpleService<ENTITY> {
private static final int DEFAULT_PAGE_INDEX = 1;
private static final int DEFAULT_PAGE_SIZE = 10;
protected final EasyEntityQuery entityQuery;
private final Class<ENTITY> target;
@Transactional(rollbackFor = Throwable.class)
@Override
public Long save(ENTITY entity) {
if (ObjectHelper.isNull(entity.getId())) {
entityQuery.insertable(entity).executeRows();
} else {
entityQuery.updatable(entity)
.setSQLStrategy(SQLExecuteStrategyEnum.ONLY_NOT_NULL_COLUMNS)
.executeRows();
}
return entity.getId();
}
@Transactional(rollbackFor = Throwable.class)
@Override
public void save(Iterable<ENTITY> entities) {
var insertList = new ArrayList<ENTITY>();
var updateList = new ArrayList<ENTITY>();
for (var entity : entities) {
if (ObjectHelper.isNull(entity.getId())) {
insertList.add(entity);
} else {
updateList.add(entity);
}
}
if (ObjectHelper.isNotEmpty(insertList)) {
entityQuery.insertable(insertList).executeRows();
}
if (ObjectHelper.isNotEmpty(updateList)) {
entityQuery.updatable(updateList)
.setSQLStrategy(SQLExecuteStrategyEnum.ONLY_NOT_NULL_COLUMNS)
.executeRows();
}
}
@Override
public Long count() {
return entityQuery.queryable(target).count();
}
@Override
public List<ENTITY> list() {
return entityQuery.queryable(target).toList();
}
@Override
public List<ENTITY> list(Set<Long> ids) {
if (ObjectHelper.isEmpty(ids)) {
return List.of();
}
return entityQuery.queryable(target)
.whereByIds(ids)
.toList();
}
protected void commonPredicates(PROXY proxy) {
}
@Override
public Page<ENTITY> list(Query query) {
var index = DEFAULT_PAGE_INDEX;
var size = DEFAULT_PAGE_SIZE;
if (ObjectHelper.isNotNull(query.page())) {
index = Math.max(ObjectHelper.defaultIfNull(query.page().index(), DEFAULT_PAGE_INDEX), 1);
size = Math.max(ObjectHelper.defaultIfNull(query.page().size(), DEFAULT_PAGE_SIZE), 1);
}
var result = entityQuery.queryable(target)
.where(this::commonPredicates)
.where(proxy -> new EqQueryParser<ENTITY, PROXY>(query.query(), proxy).build())
.orderBy(ObjectHelper.isNotEmpty(query.sort()), proxy -> query.sort().forEach(sort -> proxy.anyColumn(sort.column()).orderBy(Query.Sortable.Direction.ASC.equals(sort.direction()))))
.toPageResult(index, size);
return new Page<>(result.getData(), result.getTotal());
}
private Optional<ENTITY> detailOptional(Long id) {
if (ObjectHelper.isNull(id)) {
return Optional.empty();
}
return entityQuery.queryable(target)
.whereById(id)
.singleOptional();
}
@Named("detail")
@Override
public ENTITY detail(Long id) {
return detailOptional(id).orElse(null);
}
@Named("detailOrThrow")
@Override
public ENTITY detailOrThrow(Long id) {
return detailOptional(id).orElseThrow(() -> new IdNotFoundException(id));
}
@Transactional(rollbackFor = Throwable.class)
@Override
public void remove(Long id) {
if (ObjectHelper.isNotNull(id)) {
entityQuery.deletable(target)
.whereById(id)
.allowDeleteStatement(true)
.executeRows();
}
}
@Transactional(rollbackFor = Throwable.class)
@Override
public void remove(Set<Long> ids) {
if (ObjectHelper.isNotEmpty(ids)) {
entityQuery.deletable(target)
.whereByIds(ids)
.allowDeleteStatement(true)
.executeRows();
}
}
private static final class EqQueryParser<ENTITY extends SimpleEntity & ProxyEntityAvailable<ENTITY, PROXY>, PROXY extends AbstractProxyEntity<PROXY, ENTITY>> extends QueryParser<PROXY> {
public EqQueryParser(Query.Queryable queryable, PROXY container) {
super(queryable, container);
}
@Override
protected void nullEqual(Query.Queryable queryable, PROXY proxy) {
queryable.nullEqual().forEach(column -> proxy.anyColumn(column).isNull());
}
@Override
protected void notNullEqual(Query.Queryable queryable, PROXY proxy) {
queryable.notNullEqual().forEach(column -> proxy.anyColumn(column).isNotNull());
}
@Override
protected void empty(Query.Queryable queryable, PROXY proxy) {
throw new UnsupportedOperationException();
}
@Override
protected void notEmpty(Query.Queryable queryable, PROXY proxy) {
throw new UnsupportedOperationException();
}
@Override
protected void equal(Query.Queryable queryable, PROXY proxy) {
queryable.equal().forEach((column, value) -> proxy.anyColumn(column).eq(value));
}
@Override
protected void notEqual(Query.Queryable queryable, PROXY proxy) {
queryable.notEqual().forEach((column, value) -> proxy.anyColumn(column).ne(value));
}
@Override
protected void like(Query.Queryable queryable, PROXY proxy) {
queryable.like().forEach((column, value) -> proxy.anyColumn(column).likeRaw(value));
}
@Override
protected void notLike(Query.Queryable queryable, PROXY proxy) {
queryable.notLike().forEach((column, value) -> proxy.anyColumn(column).notLikeRaw(value));
}
@Override
protected void contain(Query.Queryable queryable, PROXY proxy) {
queryable.contain().forEach((column, value) -> proxy.anyColumn(column).like(value));
}
@Override
protected void notContain(Query.Queryable queryable, PROXY proxy) {
queryable.notContain().forEach((column, value) -> proxy.anyColumn(column).notLike(value));
}
@Override
protected void startWith(Query.Queryable queryable, PROXY proxy) {
queryable.startWith().forEach((column, value) -> proxy.anyColumn(column).likeMatchLeft(value));
}
@Override
protected void notStartWith(Query.Queryable queryable, PROXY proxy) {
queryable.notStartWith().forEach((column, value) -> proxy.anyColumn(column).notLikeMatchLeft(value));
}
@Override
protected void endWith(Query.Queryable queryable, PROXY proxy) {
queryable.endWith().forEach((column, value) -> proxy.anyColumn(column).likeMatchRight(value));
}
@Override
protected void notEndWith(Query.Queryable queryable, PROXY proxy) {
queryable.notEndWith().forEach((column, value) -> proxy.anyColumn(column).notLikeMatchRight(value));
}
@Override
protected void great(Query.Queryable queryable, PROXY proxy) {
queryable.great().forEach((column, value) -> proxy.anyColumn(column).gt(value));
}
@Override
protected void less(Query.Queryable queryable, PROXY proxy) {
queryable.less().forEach((column, value) -> proxy.anyColumn(column).lt(value));
}
@Override
protected void greatEqual(Query.Queryable queryable, PROXY proxy) {
queryable.greatEqual().forEach((column, value) -> proxy.anyColumn(column).ge(value));
}
@Override
protected void lessEqual(Query.Queryable queryable, PROXY proxy) {
queryable.lessEqual().forEach((column, value) -> proxy.anyColumn(column).le(value));
}
@Override
protected void inside(Query.Queryable queryable, PROXY proxy) {
queryable.inside()
.entrySet()
.stream()
.filter(entry -> ObjectHelper.isNotEmpty(entry.getValue()))
.forEach(entry -> proxy.anyColumn(entry.getKey()).in(entry.getValue()));
}
@Override
protected void notInside(Query.Queryable queryable, PROXY proxy) {
queryable.notInside()
.entrySet()
.stream()
.filter(entry -> ObjectHelper.isNotEmpty(entry.getValue()))
.forEach(entry -> proxy.anyColumn(entry.getKey()).notIn(entry.getValue()));
}
@Override
protected void between(Query.Queryable queryable, PROXY proxy) {
throw new UnsupportedOperationException();
}
@Override
protected void notBetween(Query.Queryable queryable, PROXY proxy) {
throw new UnsupportedOperationException();
}
}
}

View File

@@ -0,0 +1,17 @@
create table if not exists Company
(
id bigint primary key,
name varchar(255) not null,
members int not null,
created_time timestamp not null default current_timestamp(),
modified_time timestamp not null default current_timestamp() on update current_timestamp()
);
create table if not exists Employee
(
id bigint primary key,
name varchar(255) not null,
age int not null,
created_time timestamp not null default current_timestamp(),
modified_time timestamp not null default current_timestamp() on update current_timestamp()
);

View File

@@ -0,0 +1,167 @@
package com.lanyuanxiaoyao.service.template.eq;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
import org.springframework.web.client.RestTemplate;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
@Slf4j
@RequiredArgsConstructor
@SpringBootApplication
public class TestApplication {
private static final String BASE_URL = "http://localhost:2490";
private static final RestTemplate REST_CLIENT = new RestTemplate();
private static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}
@EventListener(ApplicationReadyEvent.class)
public void runTests() {
// 增
var cid1 = saveItem("company", "{\"name\": \"Apple\",\"members\": 10}").get("data").asLong();
var cid2 = saveItem("company", "{\"name\": \"Banana\",\"members\": 20}").get("data").asLong();
var cid3 = saveItem("company", "{\"name\": \"Cheery\",\"members\": 20}").get("data").asLong();
// 查
var companies = listItems("company");
Assert.isTrue(companies.at("/data/items").size() == 3, "数量错误");
Assert.isTrue(companies.at("/data/total").asLong() == 3, "返回数量错误");
// language=JSON
var companies2 = listItems("company", "{\n" +
" \"page\": {\n" +
" \"index\": 1,\n" +
" \"size\": 2\n" +
" }\n" +
"}");
Assert.isTrue(companies2.at("/data/items").size() == 2, "数量错误");
Assert.isTrue(companies2.at("/data/total").asLong() == 3, "返回数量错误");
// language=JSON
var companies3 = listItems("company", "{\n" +
" \"query\": {\n" +
" \"notNullEqual\": [\n" +
" \"name\"\n" +
" ],\n" +
" \"equal\": {\n" +
" \"name\": \"Apple\"\n" +
" },\n" +
" \"like\": {\n" +
" \"name\": \"Appl%\"\n" +
" },\n" +
" \"contain\": {\n" +
" \"name\": \"ple\"\n" +
" },\n" +
" \"startWith\": {\n" +
" \"name\": \"Appl\"\n" +
" },\n" +
" \"endWith\": {\n" +
" \"name\": \"le\"\n" +
" },\n" +
" \"less\": {\n" +
" \"members\": 50\n" +
" },\n" +
" \"greatEqual\": {\n" +
" \"members\": 0,\n" +
" \"createdTime\": \"2025-01-01 00:00:00\"\n" +
" },\n" +
" \"inside\": {\n" +
" \"name\": [\n" +
" \"Apple\",\n" +
" \"Banana\"\n" +
" ]\n" +
" }\n" +
" },\n" +
" \"page\": {\n" +
" \"index\": 1,\n" +
" \"size\": 2\n" +
" }\n" +
"}");
Assert.isTrue(companies3.at("/data/items").size() == 1, "数量错误");
Assert.isTrue(companies3.at("/data/total").asLong() == 1, "返回数量错误");
var company1 = detailItem("company", cid1);
Assert.isTrue(cid1 == company1.at("/data/id").asLong(), "id错误");
Assert.isTrue("Apple".equals(company1.at("/data/name").asString()), "name错误");
// 改
var cid4 = saveItem("company", "{\"id\": %d, \"name\": \"Dog\"}".formatted(cid2)).get("data").asLong();
Assert.isTrue(cid2 == cid4, "id错误");
var company2 = detailItem("company", cid2);
Assert.isTrue("Dog".equals(company2.at("/data/name").asString()), "name错误");
// 删
removeItem("company", cid3);
Assert.isTrue(listItems("company").at("/data/items").size() == 2, "数量错误");
Assert.isTrue(listItems("company").at("/data/total").asLong() == 2, "返回数量错误");
log.info(listItems("company").toPrettyString());
System.exit(0);
}
private HttpHeaders headers() {
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
private JsonNode saveItem(String path, String body) {
var response = REST_CLIENT.postForEntity(
"%s/%s/save".formatted(BASE_URL, path),
new HttpEntity<>(body, headers()),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private JsonNode listItems(String path) {
var response = REST_CLIENT.getForEntity(
"%s/%s/list".formatted(BASE_URL, path),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private JsonNode listItems(String path, String query) {
var response = REST_CLIENT.postForEntity(
"%s/%s/list".formatted(BASE_URL, path),
new HttpEntity<>(query, headers()),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private JsonNode detailItem(String path, Long id) {
var response = REST_CLIENT.getForEntity(
"%s/%s/detail/%d".formatted(BASE_URL, path, id),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private void removeItem(String path, Long id) {
var response = REST_CLIENT.getForEntity(
"%s/%s/remove/%d".formatted(BASE_URL, path, id),
Void.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
}
}

View File

@@ -0,0 +1,73 @@
package com.lanyuanxiaoyao.service.template.eq.controller;
import com.lanyuanxiaoyao.service.template.eq.entity.Company;
import com.lanyuanxiaoyao.service.template.eq.entity.proxy.CompanyProxy;
import com.lanyuanxiaoyao.service.template.eq.service.CompanyService;
import java.time.LocalDateTime;
import java.util.function.Function;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("company")
public class CompanyController extends SimpleControllerSupport<Company, CompanyProxy, CompanyController.SaveItem, CompanyController.ListItem, CompanyController.DetailItem> {
public CompanyController(CompanyService service) {
super(service);
}
@Override
protected Function<SaveItem, Company> saveItemMapper() {
return item -> {
var company = new Company();
company.setId(item.id());
company.setName(item.name());
company.setMembers(item.members());
return company;
};
}
@Override
protected Function<Company, ListItem> listItemMapper() {
return company -> new ListItem(
company.getId(),
company.getName(),
company.getMembers()
);
}
@Override
protected Function<Company, DetailItem> detailItemMapper() {
return company -> new DetailItem(
company.getId(),
company.getName(),
company.getMembers(),
company.getCreatedTime(),
company.getModifiedTime()
);
}
public record SaveItem(
Long id,
String name,
Integer members
) {
}
public record ListItem(
Long id,
String name,
Integer members
) {
}
public record DetailItem(
Long id,
String name,
Integer members,
LocalDateTime createdTime,
LocalDateTime modifiedTime
) {
}
}

View File

@@ -0,0 +1,21 @@
package com.lanyuanxiaoyao.service.template.eq.entity;
import com.easy.query.core.annotation.EntityProxy;
import com.easy.query.core.annotation.Table;
import com.easy.query.core.proxy.ProxyEntityAvailable;
import com.lanyuanxiaoyao.service.template.eq.entity.proxy.CompanyProxy;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
@Getter
@Setter
@ToString(callSuper = true)
@FieldNameConstants
@Table
@EntityProxy
public class Company extends SimpleEntity implements ProxyEntityAvailable<Company, CompanyProxy> {
private String name;
private Integer members;
}

View File

@@ -0,0 +1,21 @@
package com.lanyuanxiaoyao.service.template.eq.entity;
import com.easy.query.core.annotation.EntityProxy;
import com.easy.query.core.annotation.Table;
import com.easy.query.core.proxy.ProxyEntityAvailable;
import com.lanyuanxiaoyao.service.template.eq.entity.proxy.EmployeeProxy;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
@Getter
@Setter
@ToString(callSuper = true)
@FieldNameConstants
@Table
@EntityProxy
public class Employee extends SimpleEntity implements ProxyEntityAvailable<Employee, EmployeeProxy> {
private String name;
private Integer age;
}

View File

@@ -0,0 +1,13 @@
package com.lanyuanxiaoyao.service.template.eq.service;
import com.easy.query.api.proxy.client.EasyEntityQuery;
import com.lanyuanxiaoyao.service.template.eq.entity.Company;
import com.lanyuanxiaoyao.service.template.eq.entity.proxy.CompanyProxy;
import org.springframework.stereotype.Service;
@Service
public class CompanyService extends SimpleServiceSupport<Company, CompanyProxy> {
public CompanyService(EasyEntityQuery entityQuery) {
super(entityQuery, Company.class);
}
}

View File

@@ -0,0 +1,13 @@
package com.lanyuanxiaoyao.service.template.eq.service;
import com.easy.query.api.proxy.client.EasyEntityQuery;
import com.lanyuanxiaoyao.service.template.eq.entity.Employee;
import com.lanyuanxiaoyao.service.template.eq.entity.proxy.EmployeeProxy;
import org.springframework.stereotype.Service;
@Service
public class EmployeeService extends SimpleServiceSupport<Employee, EmployeeProxy> {
public EmployeeService(EasyEntityQuery entityQuery) {
super(entityQuery, Employee.class);
}
}

View File

@@ -0,0 +1,24 @@
server:
port: 2490
spring:
application:
name: Test
datasource:
url: "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL;DATABASE_TO_LOWER=TRUE;INIT=runscript from '/Users/lanyuanxiaoyao/Project/IdeaProjects/spring-boot-service-template/spring-boot-service-template-database/spring-boot-service-template-database-eq/src/test/initial.sql'"
username: test
password: test
driver-class-name: org.h2.Driver
easy-query:
database: mysql
name-conversion: underlined
print-sql: false
decorator:
datasource:
p6spy:
multiline: false
exclude-categories:
- commit
- result
- resultset
- rollback
log-format: "%(category)|%(executionTime)|%(sqlSingleLine)"

View File

@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lanyuanxiaoyao</groupId>
<artifactId>spring-boot-service-template</artifactId>
<version>1.1.0-SNAPSHOT</version>
</parent>
<artifactId>spring-boot-service-template-database-jpa</artifactId>
<dependencies>
<dependency>
<groupId>com.lanyuanxiaoyao</groupId>
<artifactId>spring-boot-service-template-database-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.blinkfox</groupId>
<artifactId>fenix-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-ant</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
<path>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-jpamodelgen</artifactId>
<version>${hibernate.version}</version>
</path>
<path>
<groupId>io.github.openfeign.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
<version>${querydsl.version}</version>
<classifier>jpa</classifier>
</path>
<path>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
<version>3.2.0</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-Aquerydsl.entityAccessors=true</arg>
<arg>-Aquerydsl.createDefaultVariable=true</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,214 @@
package com.lanyuanxiaoyao.service.template.jpa.controller;
import com.lanyuanxiaoyao.service.template.common.helper.ObjectHelper;
import com.lanyuanxiaoyao.service.template.database.common.controller.SimpleController;
import com.lanyuanxiaoyao.service.template.database.common.entity.GlobalResponse;
import com.lanyuanxiaoyao.service.template.database.common.entity.Query;
import com.lanyuanxiaoyao.service.template.jpa.entity.SimpleEntity;
import com.lanyuanxiaoyao.service.template.jpa.service.SimpleServiceSupport;
import java.util.function.Function;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* 简单控制器支持类提供基础的CRUD操作实现
* <p>
* 该类实现了基本的增删改查功能,通过泛型支持不同类型的数据转换。
* 子类需要实现对应的Mapper函数来完成实体类与传输对象之间的转换。
* </p>
*
* <h3>设计特点</h3>
* <ul>
* <li>泛型设计,支持任意实体类型和数据转换</li>
* <li>统一的异常处理和事务管理</li>
* <li>支持条件查询、分页查询和详情查询</li>
* <li>提供抽象的Mapper方法便于子类实现数据转换逻辑</li>
* </ul>
*
* <h3>使用说明</h3>
* <p>子类需要实现以下抽象方法:</p>
* <ul>
* <li>saveItemMapper(): 保存项到实体的转换函数</li>
* <li>listItemMapper(): 实体到列表项的转换函数</li>
* <li>detailItemMapper(): 实体到详情项的转换函数</li>
* </ul>
*
* @param <ENTITY> 实体类型必须继承SimpleEntity
* @param <SAVE_ITEM> 保存项类型
* @param <LIST_ITEM> 列表项类型
* @param <DETAIL_ITEM> 详情项类型
*/
@Slf4j
public abstract class SimpleControllerSupport<ENTITY extends SimpleEntity, SAVE_ITEM, LIST_ITEM, DETAIL_ITEM> implements SimpleController<SAVE_ITEM, LIST_ITEM, DETAIL_ITEM> {
protected final SimpleServiceSupport<ENTITY> service;
/**
* 构造函数
*
* @param service 简单服务支持类实例
*/
public SimpleControllerSupport(SimpleServiceSupport<ENTITY> service) {
this.service = service;
}
/**
* 保存实体对象
* <p>
* 将保存项转换为实体对象后保存返回保存后的实体ID。
* 支持新增和更新操作,通过事务保证数据一致性。
* </p>
*
* @param item 需要保存的项
* @return 返回保存后的实体ID响应对象格式{status: 0, message: "OK", data: 实体ID}
* @throws Exception 保存过程中可能抛出的异常
*/
@Transactional(rollbackFor = Throwable.class)
@PostMapping(SAVE)
@Override
public GlobalResponse<Long> save(@RequestBody SAVE_ITEM item) throws Exception {
var mapper = saveItemMapper();
return GlobalResponse.responseSuccess(service.save(mapper.apply(item)));
}
/**
* 获取所有实体列表
* <p>
* 查询所有记录,不带任何过滤条件,返回分页格式的数据。
* 将实体对象转换为列表项对象后返回。
* </p>
*
* @return 返回实体列表响应对象,格式:{status: 0, message: "OK", data: {items: [...], total: total}}
* @throws Exception 查询过程中可能抛出的异常
*/
@Transactional(readOnly = true)
@GetMapping(LIST)
@Override
public GlobalResponse<GlobalResponse.ListItem<LIST_ITEM>> list() throws Exception {
var mapper = listItemMapper();
var result = service.list();
return GlobalResponse.responseListData(
result
.stream()
.map(entity -> {
try {
return mapper.apply(entity);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList(),
result.size()
);
}
/**
* 根据查询条件获取实体列表
* <p>
* 支持复杂的查询条件、排序和分页,返回符合条件的数据。
* 将实体对象转换为列表项对象后返回。
* </p>
*
* @param query 查询条件对象,包含过滤条件、排序规则和分页信息
* @return 返回符合条件的实体列表响应对象,格式:{status: 0, message: "OK", data: {items: [...], total: total}}
* @throws Exception 查询过程中可能抛出的异常
*/
@Transactional(readOnly = true)
@PostMapping(LIST)
@Override
public GlobalResponse<GlobalResponse.ListItem<LIST_ITEM>> list(@RequestBody Query query) throws Exception {
if (ObjectHelper.isNull(query)) {
return GlobalResponse.responseListData();
}
var mapper = listItemMapper();
var result = service.list(query);
return GlobalResponse.responseListData(
result.items()
.stream()
.map(entity -> {
try {
return mapper.apply(entity);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList(),
result.total()
);
}
/**
* 根据ID获取实体详情
* <p>
* 根据主键ID查询单条记录的详细信息转换为详情项对象后返回。
* 如果记录不存在则抛出异常。
* </p>
*
* @param id 实体主键ID
* @return 返回实体详情响应对象,格式:{status: 0, message: "OK", data: 详情数据}
* @throws Exception 查询过程中可能抛出的异常
*/
@Transactional(readOnly = true)
@GetMapping(DETAIL)
@Override
public GlobalResponse<DETAIL_ITEM> detail(@PathVariable("id") Long id) throws Exception {
var mapper = detailItemMapper();
return GlobalResponse.responseSuccess(mapper.apply(service.detailOrThrow(id)));
}
/**
* 根据ID删除实体对象
* <p>
* 根据主键ID删除指定的记录执行成功后返回成功响应。
* 通过事务保证删除操作的一致性。
* </p>
*
* @param id 需要删除的实体主键ID
* @return 返回删除结果响应对象,格式:{status: 0, message: "OK", data: null}
* @throws Exception 删除过程中可能抛出的异常
*/
@Transactional(rollbackFor = Throwable.class)
@GetMapping(REMOVE)
@Override
public GlobalResponse<Object> remove(@PathVariable("id") Long id) throws Exception {
service.remove(id);
return GlobalResponse.responseSuccess();
}
/**
* 保存项映射器,将保存项转换为实体对象
* <p>
* 子类需要实现此方法,定义保存项到实体的转换逻辑。
* </p>
*
* @return Function<SAVE_ITEM, ENTITY> 保存项到实体的转换函数
*/
protected abstract Function<SAVE_ITEM, ENTITY> saveItemMapper();
/**
* 列表项映射器,将实体对象转换为列表项
* <p>
* 子类需要实现此方法,定义实体到列表项的转换逻辑。
* </p>
*
* @return Function<ENTITY, LIST_ITEM> 实体到列表项的转换函数
*/
protected abstract Function<ENTITY, LIST_ITEM> listItemMapper();
/**
* 详情项映射器,将实体对象转换为详情项
* <p>
* 子类需要实现此方法,定义实体到详情项的转换逻辑。
* </p>
*
* @return Function<ENTITY, DETAIL_ITEM> 实体到详情项的转换函数
*/
protected abstract Function<ENTITY, DETAIL_ITEM> detailItemMapper();
public interface Mapper<S, T> {
T map(S source) throws Exception;
}
}

View File

@@ -0,0 +1,48 @@
package com.lanyuanxiaoyao.service.template.jpa.entity;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
/**
* 仅包含ID的基础实体类
* <p>
* 该类作为所有实体类的基类仅提供ID字段使用Snowflake算法生成唯一标识。
* 通过继承此类实体类可以自动获得唯一ID字段以及相关的JPA注解配置。
* </p>
*
* <p>
* 该类使用了以下注解:
* <ul>
* <li>@MappedSuperclass: 标识该类为JPA映射的超类其属性会被映射到子类的数据库表字段中</li>
* <li>@EntityListeners(AuditingEntityListener.class): 启用Spring Data JPA的审计功能</li>
* <li>@Id: 标识id字段为主键</li>
* <li>@SnowflakeId: 使用Snowflake算法生成唯一ID</li>
* <li>@Comment: 为数据库字段添加注释</li>
* </ul>
* </p>
*/
@Getter
@Setter
@ToString
@FieldNameConstants
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class IdOnlyEntity {
/**
* 实体唯一标识符
* <p>
* 使用Snowflake算法生成的Long类型ID保证全局唯一性。
* </p>
*/
@Id
@SnowflakeId
@Column(comment = "记录唯一标记")
private Long id;
}

View File

@@ -0,0 +1,60 @@
package com.lanyuanxiaoyao.service.template.jpa.entity;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
/**
* 简单实体类,包含基础字段
* <p>
* 该类继承自IdOnlyEntity除了具备唯一ID外还提供了创建时间和修改时间字段。
* 通过Spring Data JPA的审计功能自动维护时间字段适用于大多数业务实体场景。
* </p>
*
* <p>
* 该类使用了以下注解:
* <ul>
* <li>@MappedSuperclass: 标识该类为JPA映射的超类其属性会被映射到子类的数据库表字段中</li>
* <li>@EntityListeners(AuditingEntityListener.class): 启用Spring Data JPA的审计功能</li>
* <li>@CreatedDate: 自动设置实体创建时间</li>
* <li>@LastModifiedDate: 自动更新实体最后修改时间</li>
* <li>@Comment: 为数据库字段添加注释</li>
* </ul>
* </p>
*
*/
@Getter
@Setter
@ToString(callSuper = true)
@FieldNameConstants
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class SimpleEntity extends IdOnlyEntity {
/**
* 记录创建时间
* <p>
* 由Spring Data JPA自动维护当实体首次持久化时设置该字段的值。
* </p>
*/
@CreatedDate
@Column(comment = "记录创建时间")
private LocalDateTime createdTime;
/**
* 记录更新时间
* <p>
* 由Spring Data JPA自动维护当实体每次更新时刷新该字段的值。
* </p>
*/
@LastModifiedDate
@Column(comment = "记录更新时间")
private LocalDateTime modifiedTime;
}

View File

@@ -0,0 +1,13 @@
package com.lanyuanxiaoyao.service.template.jpa.entity;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.hibernate.annotations.IdGeneratorType;
@IdGeneratorType(SnowflakeIdGenerator.class)
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD, ElementType.METHOD })
public @interface SnowflakeId {
}

View File

@@ -0,0 +1,20 @@
package com.lanyuanxiaoyao.service.template.jpa.entity;
import com.lanyuanxiaoyao.service.template.database.common.helper.SnowflakeHelper;
import java.io.Serializable;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.id.IdentifierGenerator;
@Slf4j
public class SnowflakeIdGenerator implements IdentifierGenerator {
@Override
public Serializable generate(SharedSessionContractImplementor session, Object object) {
try {
return SnowflakeHelper.next();
} catch (Exception e) {
log.error("Generate snowflake id failed", e);
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,264 @@
package com.lanyuanxiaoyao.service.template.jpa.helper;
import jakarta.persistence.Entity;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.Set;
import org.hibernate.boot.MetadataSources;
import org.hibernate.boot.model.naming.PhysicalNamingStrategySnakeCaseImpl;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.hibernate.tool.hbm2ddl.SchemaExport;
import org.hibernate.tool.schema.TargetType;
import org.springframework.boot.hibernate.SpringImplicitNamingStrategy;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.util.ClassUtils;
/**
* 构造DDL建表语句
* <p>
* 该工具类用于生成数据库表结构的DDL语句通过扫描指定包下的实体类
* 利用Hibernate的SchemaExport工具生成建表SQL脚本。
* </p>
*
* <p>
* 使用示例:
* <pre>
* DDLGenerator.generateDDL(
* List.of("com.example.entity", "com.another.package"),
* "./sql",
* MySQL8Dialect.class,
* "jdbc:mysql://localhost:3306/test",
* "username",
* "password",
* com.mysql.cj.jdbc.Driver.class
* );
* </pre>
* </p>
*/
public class DatabaseHelper {
public static void generateDDL(
Set<String> entityPackages,
String ddlFilePath,
Class<?> dialect,
String jdbc,
String username,
String password,
Class<?> driver
) {
var metadataSources = new MetadataSources(
new StandardServiceRegistryBuilder()
.applySetting("hibernate.dialect", dialect.getName())
.applySetting("hibernate.physical_naming_strategy", PhysicalNamingStrategySnakeCaseImpl.class.getName())
.applySetting("hibernate.implicit_naming_strategy", SpringImplicitNamingStrategy.class.getName())
.applySetting("hibernate.connection.url", jdbc)
.applySetting("hibernate.connection.username", username)
.applySetting("hibernate.connection.password", password)
.applySetting("hibernate.connection.driver_class", driver.getName())
.build()
);
for (String className : scanEntityPackage(entityPackages)) {
try {
var entityClass = ClassUtils.forName(className, DatabaseHelper.class.getClassLoader());
metadataSources.addAnnotatedClass(entityClass);
} catch (ClassNotFoundException e) {
throw new RuntimeException("Failed to load entity class: " + className, e);
}
}
var export = new SchemaExport();
export.setFormat(true);
export.setDelimiter(";");
export.setOutputFile(ddlFilePath + "/" + dialect.getSimpleName() + ".sql");
export.setOverrideOutputFileContent();
export.execute(EnumSet.of(TargetType.SCRIPT), SchemaExport.Action.CREATE, metadataSources.buildMetadata());
}
/**
* 兼容旧版本的方法签名
*
* @param entityPackage 实体类包路径
* @param ddlFilePath DDL文件输出路径
* @param dialect 方言类
* @param jdbc JDBC连接URL
* @param username 数据库用户名
* @param password 数据库密码
* @param driver JDBC驱动类
*/
public static void generateDDL(
String entityPackage,
String ddlFilePath,
Class<?> dialect,
String jdbc,
String username,
String password,
Class<?> driver
) {
generateDDL(Set.of(entityPackage), ddlFilePath, dialect, jdbc, username, password, driver);
}
public static void generateBasicFiles(Set<String> entityPackages, String projectRootPackage, String projectRootPath, boolean override) throws IOException {
for (String className : scanEntityPackage(entityPackages)) {
try {
var entityClass = ClassUtils.forName(className, DatabaseHelper.class.getClassLoader());
var name = entityClass.getSimpleName();
// Repository
Files.createDirectories(Path.of(projectRootPath, "repository"));
var repositoryFilePath = Path.of(projectRootPath, "repository", name + "Repository.java");
if (Files.notExists(repositoryFilePath) || override) {
Files.writeString(repositoryFilePath, """
package %s.repository;
import %s;
import com.lanyuanxiaoyao.service.template.repository.SimpleRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface %sRepository extends SimpleRepository<%s> {
}
""".formatted(projectRootPackage, className, name, name));
}
// Service
Files.createDirectories(Path.of(projectRootPath, "service"));
var serviceFilePath = Path.of(projectRootPath, "service", name + "Service.java");
if (Files.notExists(serviceFilePath) || override) {
Files.writeString(serviceFilePath, """
package %s.service;
import %s;
import %s.repository.%sRepository;
import com.lanyuanxiaoyao.service.template.service.SimpleServiceSupport;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class %sService extends SimpleServiceSupport<%s> {
public %sService(%sRepository repository) {
super(repository);
}
}
""".formatted(projectRootPackage, className, projectRootPackage, name, name, name, name, name));
}
// Controller
Files.createDirectories(Path.of(projectRootPath, "controller"));
var controllerFilePath = Path.of(projectRootPath, "controller", name + "Controller.java");
if (Files.notExists(controllerFilePath) || override) {
Files.writeString(controllerFilePath, """
package %s.controller;
import %s;
import %s.service.%sService;
import com.lanyuanxiaoyao.service.template.controller.SimpleControllerSupport;
import java.util.function.Function;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("%s")
public class %sController extends SimpleControllerSupport<%s, %sController.SaveItem, %sController.ListItem, %sController.DetailItem> {
public %sController(%sService service) {
super(service);
}
@Override
protected Function<SaveItem, %s> saveItemMapper() {
return null;
}
@Override
protected Function<%s, ListItem> listItemMapper() {
return null;
}
@Override
protected Function<%s, DetailItem> detailItemMapper() {
return null;
}
public record SaveItem() {
}
public record ListItem() {
}
public record DetailItem() {
}
}
""".formatted(projectRootPackage, className, projectRootPackage, name, camelConvert(name), name, name, name, name, name, name, name, name, name, name));
}
} catch (ClassNotFoundException e) {
throw new RuntimeException("Failed to load entity class: " + className, e);
}
}
}
public static void generateBasicFiles(String entityPackage, String projectRootPackage, String projectRootPath, boolean override) throws IOException {
generateBasicFiles(Set.of(entityPackage), projectRootPackage, projectRootPath, override);
}
private static String camelConvert(String camelCase) {
if (camelCase == null || camelCase.isEmpty()) {
return camelCase;
}
StringBuilder result = new StringBuilder(camelCase.length() * 2); // 预分配更多空间以避免重新分配
// 处理第一个字符,直接转为小写
result.append(Character.toLowerCase(camelCase.charAt(0)));
// 处理剩余字符
for (int i = 1; i < camelCase.length(); i++) {
char currentChar = camelCase.charAt(i);
// 如果是大写字母,则在前面添加下划线
if (Character.isUpperCase(currentChar)) {
// 在特定条件下添加下划线:
// 1. 前一个字符是小写
// 2. 前一个字符是数字
// 3. 当前大写字母不是最后一个字符,且下一个字符是小写(处理连续大写字母如"XMLParser" -> "xml_parser"
char previousChar = camelCase.charAt(i - 1);
if (Character.isLowerCase(previousChar)
|| Character.isDigit(previousChar)
|| (i < camelCase.length() - 1 && Character.isLowerCase(camelCase.charAt(i + 1)))) {
result.append('_');
}
result.append(Character.toLowerCase(currentChar));
} else {
result.append(currentChar);
}
}
return result.toString();
}
private static Set<String> scanEntityPackage(Set<String> entityPackages) {
var scanner = new ClassPathScanningCandidateComponentProvider(false);
scanner.addIncludeFilter(new AnnotationTypeFilter(Entity.class));
var classNames = new HashSet<String>();
for (String entityPackage : entityPackages) {
var candidates = scanner.findCandidateComponents(entityPackage);
// 将找到的实体类添加到metadataSources中
for (var candidate : candidates) {
// 处理candidate或getBeanClassName可能为null的情况
if (candidate == null || candidate.getBeanClassName() == null) {
continue;
}
classNames.add(candidate.getBeanClassName());
}
}
return classNames;
}
}

View File

@@ -0,0 +1,103 @@
package com.lanyuanxiaoyao.service.template.jpa.repository;
import com.blinkfox.fenix.jpa.FenixJpaRepository;
import com.blinkfox.fenix.specification.FenixJpaSpecificationExecutor;
import org.springframework.data.querydsl.ListQuerydslPredicateExecutor;
import org.springframework.data.repository.NoRepositoryBean;
import org.springframework.data.repository.query.ListQueryByExampleExecutor;
/**
* 简单仓库接口,整合多种数据访问功能
* <p>
* 该接口继承了多个Spring Data JPA和扩展功能接口为数据访问层提供丰富的查询和操作能力。
* 通过继承此接口可以快速实现常见的数据访问功能包括基本CRUD操作、复杂条件查询、
* 示例查询和QueryDSL查询等。
* </p>
*
* <p>
* 继承的接口功能说明:
* <ul>
* <li>
* FenixJpaRepository: 扩展的JPA仓库接口提供基本的CRUD操作、分页和排序功能。
* <p><b>常用方法示例:</b></p>
* <pre>
* // 保存实体
* User user = new User("张三", "zhangsan@example.com");
* User savedUser = repository.save(user);
*
* // 根据ID查找
* Optional<User> user = repository.findById(1L);
*
* // 查找所有
* List<User> users = repository.findAll();
*
* // 分页查询
* Pageable pageable = PageRequest.of(0, 10, Sort.by("createdTime").descending());
* Page<User> userPages = repository.findAll(pageable);
*
* // 根据ID删除
* repository.deleteById(1L);
* </pre>
* </li>
* <li>
* FenixJpaSpecificationExecutor: Fenix扩展的JPA规范执行器支持通过Specification构建复杂查询条件。
* <p><b>常用方法示例:</b></p>
* <pre>
* // 使用Specification构建复杂查询
* Specification<User> spec = (root, query, criteriaBuilder) -> {
* List<Predicate> predicates = new ArrayList<>();
* predicates.add(criteriaBuilder.equal(root.get("status"), "ACTIVE"));
* predicates.add(criteriaBuilder.like(root.get("name"), "%张三%"));
* return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
* };
* List<User> users = repository.findAll(spec);
*
* // 分页查询
* Page<User> userPages = repository.findAll(spec, pageable);
* </pre>
* </li>
* <li>
* QueryByExampleExecutor: 示例查询执行器,通过示例对象进行查询。
* <p><b>常用方法示例:</b></p>
* <pre>
* // 使用示例对象查询
* User exampleUser = new User();
* exampleUser.setName("张三");
* exampleUser.setStatus("ACTIVE");
* Example<User> example = Example.of(exampleUser);
* List<User> users = repository.findAll(example);
*
* // 使用匹配器增强查询
* ExampleMatcher matcher = ExampleMatcher.matching()
* .withMatcher("name", ExampleMatcher.GenericPropertyMatchers.startsWith())
* .withIgnorePaths("createdTime");
* Example<User> exampleWithMatcher = Example.of(exampleUser, matcher);
* List<User> matchedUsers = repository.findAll(exampleWithMatcher);
* </pre>
* </li>
* <li>
* QuerydslPredicateExecutor: QueryDSL查询执行器支持类型安全的查询构建。
* <p><b>常用方法示例:</b></p>
* <pre>
* // 使用QueryDSL构建类型安全查询
* QUser qUser = QUser.user;
* Iterable<User> users = repository.findAll(
* qUser.status.eq("ACTIVE")
* .and(qUser.name.like("%张三%"))
* );
*
* // 排序和分页
* Iterable<User> sortedUsers = repository.findAll(
* qUser.status.eq("ACTIVE"),
* qUser.createdTime.desc()
* );
* </pre>
* </li>
* </ul>
* </p>
*
* @param <E> 实体类型
*/
@NoRepositoryBean
public interface SimpleRepository<E> extends FenixJpaRepository<E, Long>, FenixJpaSpecificationExecutor<E>, ListQueryByExampleExecutor<E>, ListQuerydslPredicateExecutor<E> {
}

View File

@@ -0,0 +1,628 @@
package com.lanyuanxiaoyao.service.template.jpa.service;
import com.lanyuanxiaoyao.service.template.common.helper.ObjectHelper;
import com.lanyuanxiaoyao.service.template.database.common.entity.Page;
import com.lanyuanxiaoyao.service.template.database.common.entity.Query;
import com.lanyuanxiaoyao.service.template.database.common.exception.IdNotFoundException;
import com.lanyuanxiaoyao.service.template.database.common.exception.NotCollectionException;
import com.lanyuanxiaoyao.service.template.database.common.exception.NotComparableException;
import com.lanyuanxiaoyao.service.template.database.common.exception.NotStringException;
import com.lanyuanxiaoyao.service.template.database.common.service.QueryParser;
import com.lanyuanxiaoyao.service.template.database.common.service.SimpleService;
import com.lanyuanxiaoyao.service.template.jpa.entity.IdOnlyEntity;
import com.lanyuanxiaoyao.service.template.jpa.entity.SimpleEntity;
import com.lanyuanxiaoyao.service.template.jpa.repository.SimpleRepository;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.mapstruct.Named;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;
/**
* 简单服务支持类,提供基础的业务逻辑实现
* <p>
* 该类实现了SimpleService接口提供实体的增删改查等基本操作。
* 通过继承此类,可以快速实现常见的业务逻辑功能,包括:
* <ul>
* <li>实体的保存和更新(支持部分字段更新)</li>
* <li>实体的条件查询和分页查询</li>
* <li>实体的详情查询(多种方式)</li>
* <li>实体的删除操作(支持批量删除)</li>
* <li>动态查询条件构建</li>
* </ul>
* </p>
*
* <h3>设计特点</h3>
* <ul>
* <li>泛型设计,支持任意实体类型</li>
* <li>事务管理,确保数据一致性</li>
* <li>动态查询条件,支持复杂的业务查询</li>
* <li>部分更新,只更新非空字段</li>
* <li>可扩展的查询条件构建</li>
* </ul>
*
* <h3>使用说明</h3>
* <p>子类可以重写以下方法:</p>
* <ul>
* <li>commonPredicates(): 添加自定义的查询条件</li>
* </ul>
*
* @param <ENTITY> 实体类型必须继承SimpleEntity
*/
@Slf4j
public abstract class SimpleServiceSupport<ENTITY extends SimpleEntity> implements SimpleService<ENTITY> {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final int DEFAULT_PAGE_INDEX = 1;
private static final int DEFAULT_PAGE_SIZE = 10;
protected final SimpleRepository<ENTITY> repository;
/**
* 构造函数
*
* @param repository 简单仓库实例
*/
public SimpleServiceSupport(SimpleRepository<ENTITY> repository) {
this.repository = repository;
}
/**
* 保存实体对象
* <p>
* 使用saveOrUpdateByNotNullProperties方法保存实体只更新非空字段。
* 该方法具有事务性,遇到任何异常都会回滚。
* </p>
*
* @param entity 需要保存的实体对象
* @return 返回保存后的实体ID
*/
@Transactional(rollbackFor = Throwable.class)
@Override
public Long save(ENTITY entity) {
entity = repository.saveOrUpdateByNotNullProperties(entity);
return entity.getId();
}
/**
* 批量保存实体对象集合
* <p>
* 使用saveOrUpdateAllByNotNullProperties方法只更新非空字段。
* 该方法具有事务性,遇到任何异常都会回滚。
* </p>
*
* @param entities 需要保存的实体对象集合
*/
@Transactional(rollbackFor = Throwable.class)
@Override
public void save(Iterable<ENTITY> entities) {
repository.saveOrUpdateAllByNotNullProperties(entities);
}
/**
* 统计符合条件的实体数量
* <p>
* 根据listPredicate方法构建的条件统计实体数量。
* </p>
*
* @return 返回符合条件的实体数量
*/
@Override
public Long count() {
return repository.count(this::commonPredicates);
}
/**
* 获取所有符合条件的实体列表
* <p>
* 根据listPredicate方法构建的条件查询所有实体。
* </p>
*
* @return 返回符合条件的实体列表
*/
@Override
public List<ENTITY> list() {
return repository.findAll(this::commonPredicates);
}
/**
* 根据ID集合获取实体列表
* <p>
* 根据提供的ID集合查询对应的实体列表并结合listPredicate方法构建的条件。
* </p>
*
* @param ids ID集合
* @return 返回ID集合对应的实体列表
*/
@Override
public List<ENTITY> list(Set<Long> ids) {
if (ObjectHelper.isEmpty(ids)) {
return List.of();
}
return repository.findAll(
(root, query, builder) -> {
var predicate = commonPredicates(root, query, builder);
var idsPredicate = builder.in(root.get(IdOnlyEntity.Fields.id)).value(ids);
return ObjectHelper.isNull(predicate)
? idsPredicate
: builder.and(predicate, idsPredicate);
}
);
}
protected Predicate commonPredicates(Root<ENTITY> root, CriteriaQuery<?> query, CriteriaBuilder builder) {
return null;
}
/**
* 根据查询条件分页获取实体列表
* <p>
* 支持复杂的查询条件和分页功能。
* 默认分页参数第1页每页10条记录按创建时间降序排列。
* </p>
*
* @param listQuery 查询条件对象
* @return 返回分页查询结果
*/
@Override
public Page<ENTITY> list(Query listQuery) {
var pageRequest = PageRequest.of(DEFAULT_PAGE_INDEX - 1, DEFAULT_PAGE_SIZE, Sort.by(SimpleEntity.Fields.createdTime).descending());
if (ObjectHelper.isNotNull(listQuery.page())) {
var index = Math.max(ObjectHelper.defaultIfNull(listQuery.page().index(), DEFAULT_PAGE_INDEX) - 1, 0);
var size = Math.max(ObjectHelper.defaultIfNull(listQuery.page().size(), DEFAULT_PAGE_SIZE), 1);
if (ObjectHelper.isNotEmpty(listQuery.sort())) {
pageRequest = PageRequest.of(index, size, Sort.by(
listQuery.sort()
.stream()
.map(sort -> new Sort.Order(Sort.Direction.fromString(sort.direction().name()), sort.column()))
.toList()
));
} else {
pageRequest = PageRequest.of(index, size, Sort.by(SimpleEntity.Fields.createdTime).descending());
}
}
var result = repository.findAll(
(root, query, builder) -> {
var predicate = commonPredicates(root, query, builder);
var predicates = new ArrayList<Predicate>();
new JpaQueryParser<>(listQuery.query(), predicates, root, query, builder).build();
var queryPredicate = predicates.size() == 1
? predicates.get(0)
: builder.and(predicates.toArray(Predicate[]::new));
return ObjectHelper.isNull(predicate)
? queryPredicate
: builder.and(predicate, queryPredicate);
},
pageRequest
);
return new Page<>(result.get().toList(), result.getTotalElements());
}
/**
* 根据ID获取实体详情Optional包装
* <p>
* 如果ID为空则返回空Optional否则根据ID查询实体。
* </p>
*
* @param id 实体ID
* @return 返回实体详情的Optional包装
*/
private Optional<ENTITY> detailOptional(Long id) {
if (ObjectHelper.isNull(id)) {
return Optional.empty();
}
return repository.findOne(
(root, query, builder) -> {
var predicate = commonPredicates(root, query, builder);
var idPredicate = builder.equal(root.get(IdOnlyEntity.Fields.id), id);
return ObjectHelper.isNull(predicate)
? idPredicate
: builder.and(predicate, idPredicate);
}
);
}
/**
* 根据ID获取实体详情
* <p>
* 如果实体不存在则返回null。
* </p>
*
* @param id 实体ID
* @return 返回实体详情不存在时返回null
*/
@Named("detail")
@Override
public ENTITY detail(Long id) {
return detailOptional(id).orElse(null);
}
/**
* 根据ID获取实体详情不存在时抛出异常
* <p>
* 如果实体不存在则抛出IdNotFoundException异常。
* </p>
*
* @param id 实体ID
* @return 返回实体详情
* @throws IdNotFoundException 当实体不存在时抛出
*/
@Named("detailOrThrow")
@Override
public ENTITY detailOrThrow(Long id) {
return detailOptional(id).orElseThrow(() -> new IdNotFoundException(id));
}
/**
* 根据ID删除实体
* <p>
* 具有事务性,遇到任何异常都会回滚。
* 如果ID为空则不执行任何操作。
* </p>
*
* @param id 实体主键ID
*/
@Transactional(rollbackFor = Throwable.class)
@Override
public void remove(Long id) {
if (ObjectHelper.isNotNull(id)) {
repository.deleteById(id);
}
}
/**
* 根据ID集合批量删除实体
* <p>
* 使用deleteAllById方法根据ID集合批量删除实体。
* 该方法具有事务性,遇到任何异常都会回滚。
* 如果ID集合为空则不执行任何操作。
* </p>
*
* @param ids 实体主键ID集合
*/
@Transactional(rollbackFor = Throwable.class)
@Override
public void remove(Set<Long> ids) {
if (ObjectHelper.isNotEmpty(ids)) {
repository.deleteBatchByIds(ids);
}
}
@SuppressWarnings("unchecked")
private static final class JpaQueryParser<ENTITY> extends QueryParser<List<Predicate>> {
private final Root<ENTITY> root;
@SuppressWarnings({"unused", "FieldCanBeLocal"})
private final CriteriaQuery<?> query;
private final CriteriaBuilder builder;
private JpaQueryParser(Query.Queryable queryable, List<Predicate> predicates, Root<ENTITY> root, CriteriaQuery<?> query, CriteriaBuilder builder) {
super(queryable, predicates);
this.root = root;
this.query = query;
this.builder = builder;
}
/**
* 解析字段路径
* <p>
* 支持多级字段路径解析,使用"."分隔多级字段。
* 例如: "user.name" 表示实体的user属性的name字段。
* </p>
*
* @param root JPA Criteria查询根节点
* @param column 字段路径字符串
* @param <Y> 字段类型
* @return 返回字段路径对象
* @throws IllegalArgumentException 当字段路径为空时抛出
*/
private <Y> Path<Y> column(Root<ENTITY> root, String column) {
if (ObjectHelper.isEmpty(column)) {
throw new IllegalArgumentException("Column cannot be blank");
}
var columns = column.split("\\.");
Path<Y> path = root.get(columns[0]);
for (int i = 1; i < columns.length; i++) {
path = path.get(columns[i]);
}
return path;
}
/**
* 处理字段值
* <p>
* 对于枚举类型字段,将字符串值转换为对应的枚举值。
* 对于LocalDateTime类型字段将字符串转换为时间对象。
* 其他类型直接返回原值。
* </p>
*
* @param column 字段路径
* @param value 字段值
* @param <Y> 字段类型
* @return 处理后的字段值
* @throws IllegalArgumentException 当枚举类型字段的值不是字符串时抛出
*/
@SuppressWarnings({"unchecked", "rawtypes"})
private <Y> Object value(Path<Y> column, Object value) {
if (ObjectHelper.isNull(value)) {
return null;
}
var javaType = column.getJavaType();
if (javaType.isEnum()) {
if (value instanceof String enumName) {
var enumType = (Class<Enum>) javaType;
return Enum.valueOf(enumType, enumName);
} else {
throw new IllegalArgumentException("枚举类型字段需要 String 类型的值");
}
} else if (javaType.isAssignableFrom(LocalDateTime.class)) {
return LocalDateTime.parse(String.valueOf(value), DATE_TIME_FORMATTER);
}
return value;
}
/**
* 检查字段类型是否可比较
*
* @param path 字段路径
* @param value 比较值
* @param column 字段名称
* @throws NotComparableException 当字段类型不可比较时抛出
*/
private void checkComparable(Path<?> path, Object value, String column) {
if (!ObjectHelper.isComparable(path.getJavaType()) || !ObjectHelper.isComparable(value)) {
throw new NotComparableException(column);
}
}
/**
* 检查区间值是否可比较
*
* @param path 字段路径
* @param value 区间对象
* @param column 字段名称
* @throws NotComparableException 当区间值不可比较时抛出
*/
private void checkComparable(Path<?> path, Query.Queryable.Between value, String column) {
checkComparable(path, value.start(), column);
checkComparable(path, value.end(), column);
}
/**
* 检查字段类型是否为集合
*
* @param path 字段路径
* @param column 字段名称
* @throws NotCollectionException 当字段类型不是集合时抛出
*/
private void checkCollection(Path<?> path, String column) {
if (!ObjectHelper.isCollection(path.getJavaType())) {
throw new NotCollectionException(column);
}
}
/**
* 检查值是否为集合
*
* @param value 值对象
* @param column 字段名称
* @throws NotCollectionException 当值不是集合时抛出
*/
private void checkCollection(Object value, String column) {
if (!ObjectHelper.isCollection(value)) {
throw new NotCollectionException(column);
}
}
/**
* 检查字段类型是否为字符串
*
* @param path 字段路径
* @param value 比较值
* @param column 字段名称
* @throws NotStringException 当字段类型不是字符串时抛出
*/
private void checkString(Path<?> path, Object value, String column) {
if (!ObjectHelper.isString(path.getJavaType()) || !ObjectHelper.isString(value)) {
throw new NotStringException(column);
}
}
@Override
protected void nullEqual(Query.Queryable queryable, List<Predicate> predicates) {
queryable.nullEqual().forEach(column -> predicates.add(builder.isNull(column(root, column))));
}
@Override
protected void notNullEqual(Query.Queryable queryable, List<Predicate> predicates) {
queryable.notNullEqual().forEach(column -> predicates.add(builder.isNotNull(column(root, column))));
}
@Override
protected void empty(Query.Queryable queryable, List<Predicate> predicates) {
queryable.empty().forEach(column -> {
var path = this.<Collection<Object>>column(root, column);
checkCollection(path, column);
predicates.add(builder.isEmpty(path));
});
}
@Override
protected void notEmpty(Query.Queryable queryable, List<Predicate> predicates) {
queryable.notEmpty().forEach(column -> {
var path = this.<Collection<Object>>column(root, column);
checkCollection(path, column);
predicates.add(builder.isNotEmpty(path));
});
}
@Override
protected void equal(Query.Queryable queryable, List<Predicate> predicates) {
queryable.equal().forEach((column, value) -> {
var path = column(root, column);
predicates.add(builder.equal(path, value(path, value)));
});
}
@Override
protected void notEqual(Query.Queryable queryable, List<Predicate> predicates) {
queryable.notEqual().forEach((column, value) -> {
var path = column(root, column);
predicates.add(builder.notEqual(path, value(path, value)));
});
}
@Override
protected void like(Query.Queryable queryable, List<Predicate> predicates) {
queryable.like().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.like(path, value));
});
}
@Override
protected void notLike(Query.Queryable queryable, List<Predicate> predicates) {
queryable.notLike().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.notLike(path, value));
});
}
@Override
protected void contain(Query.Queryable queryable, List<Predicate> predicates) {
queryable.contain().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.like(path, "%" + value + "%"));
});
}
@Override
protected void notContain(Query.Queryable queryable, List<Predicate> predicates) {
queryable.notContain().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.notLike(path, "%" + value + "%"));
});
}
@Override
protected void startWith(Query.Queryable queryable, List<Predicate> predicates) {
queryable.startWith().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.like(path, value + "%"));
});
}
@Override
protected void notStartWith(Query.Queryable queryable, List<Predicate> predicates) {
queryable.notStartWith().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.notLike(path, value + "%"));
});
}
@Override
protected void endWith(Query.Queryable queryable, List<Predicate> predicates) {
queryable.endWith().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.like(path, "%" + value));
});
}
@Override
protected void notEndWith(Query.Queryable queryable, List<Predicate> predicates) {
queryable.notEndWith().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.notLike(path, "%" + value));
});
}
@Override
protected void great(Query.Queryable queryable, List<Predicate> predicates) {
queryable.great().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.greaterThan(path, (Comparable<Object>) value(path, value)));
});
}
@Override
protected void less(Query.Queryable queryable, List<Predicate> predicates) {
queryable.less().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.lessThan(path, (Comparable<Object>) value(path, value)));
});
}
@Override
protected void greatEqual(Query.Queryable queryable, List<Predicate> predicates) {
queryable.greatEqual().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.greaterThanOrEqualTo(path, (Comparable<Object>) value(path, value)));
});
}
@Override
protected void lessEqual(Query.Queryable queryable, List<Predicate> predicates) {
queryable.lessEqual().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.lessThanOrEqualTo(path, (Comparable<Object>) value(path, value)));
});
}
@Override
protected void inside(Query.Queryable queryable, List<Predicate> predicates) {
queryable.inside()
.entrySet()
.stream()
.filter(entry -> ObjectHelper.isNotEmpty(entry.getValue()))
.forEach(entry -> predicates.add(builder.in(column(root, entry.getKey())).value(entry.getValue())));
}
@Override
protected void notInside(Query.Queryable queryable, List<Predicate> predicates) {
queryable.notInside()
.entrySet()
.stream()
.filter(entry -> ObjectHelper.isNotEmpty(entry.getValue()))
.forEach(entry -> predicates.add(builder.in(column(root, entry.getKey())).value(entry.getValue()).not()));
}
@Override
protected void between(Query.Queryable queryable, List<Predicate> predicates) {
queryable.between().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.between(path, (Comparable<Object>) value(path, value.start()), (Comparable<Object>) value(path, value.end())));
});
}
@Override
protected void notBetween(Query.Queryable queryable, List<Predicate> predicates) {
queryable.notBetween().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.between(path, (Comparable<Object>) value(path, value.start()), (Comparable<Object>) value(path, value.end())).not());
});
}
}
}

View File

@@ -0,0 +1,89 @@
package com.lanyuanxiaoyao.service.template.jpa;
import com.lanyuanxiaoyao.service.template.jpa.helper.DatabaseHelper;
import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.Assert;
/**
* Helper测试类
* 用于测试驼峰命名法转下划线命名法的功能
*/
@Slf4j
public class HelperTest {
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
// 通过反射调用Helper类中的private静态方法camelConvert
var camelConvert = DatabaseHelper.class.getDeclaredMethod("camelConvert", String.class);
camelConvert.setAccessible(true);
// 测试用例集合
Map<String, String> testCases = new HashMap<>();
// 基本转换测试
testCases.put("helloWorld", "hello_world");
testCases.put("firstName", "first_name");
testCases.put("lastName", "last_name");
testCases.put("URL", "url");
testCases.put("HTTPResponse", "http_response");
testCases.put("XMLParser", "xml_parser");
// 边界情况测试
testCases.put(null, null); // null输入
testCases.put("", ""); // 空字符串
testCases.put("a", "a"); // 单个小写字母
testCases.put("A", "a"); // 单个大写字母
testCases.put("aB", "a_b"); // 两个字符
testCases.put("Ab", "ab"); // 首字母大写
// 数字相关测试
testCases.put("field1Name", "field1_name");
testCases.put("field12Name", "field12_name");
testCases.put("2FARequired", "2_fa_required");
testCases.put("ID", "id");
testCases.put("userID", "user_id");
testCases.put("HTML5Parser", "html5_parser");
// 连续大写字母测试
testCases.put("HTTPSConnection", "https_connection");
testCases.put("XMLHttpRequest", "xml_http_request");
testCases.put("URLPath", "url_path");
testCases.put("APIKey", "api_key");
testCases.put("JWTToken", "jwt_token");
// 特殊场景测试
testCases.put("iPhone", "i_phone"); // 以小写字母开头,后面有大写
testCases.put("iOSVersion", "i_os_version"); // 连续小写字母后跟大写
testCases.put("CAPTCHA", "captcha"); // 全大写字母缩写
log.info("开始执行驼峰命名转下划线命名测试...");
int passedTests = 0;
int totalTests = testCases.size();
for (Map.Entry<String, String> testCase : testCases.entrySet()) {
String input = testCase.getKey();
String expected = testCase.getValue();
String actual = (String) camelConvert.invoke(null, input);
try {
Assert.isTrue(Objects.equals(expected, actual), "测试失败: 输入='%s', 期望='%s', 实际='%s'".formatted(input, expected, actual));
passedTests++;
log.info("✓ 测试通过: '{}' -> '{}'", input, actual);
} catch (Exception e) {
log.error("✗ {}", e.getMessage());
}
}
log.info("测试结果: {}/{} 通过", passedTests, totalTests);
if (passedTests == totalTests) {
log.info("所有测试通过!✓");
} else {
log.error("有测试失败!✗");
System.exit(1);
}
}
}

View File

@@ -0,0 +1,302 @@
package com.lanyuanxiaoyao.service.template.jpa;
import com.blinkfox.fenix.EnableFenix;
import com.lanyuanxiaoyao.service.template.jpa.entity.Company;
import com.lanyuanxiaoyao.service.template.jpa.entity.Company_;
import com.lanyuanxiaoyao.service.template.jpa.entity.Employee;
import com.lanyuanxiaoyao.service.template.jpa.entity.Employee_;
import com.lanyuanxiaoyao.service.template.jpa.entity.QEmployee;
import com.lanyuanxiaoyao.service.template.jpa.entity.Report;
import com.lanyuanxiaoyao.service.template.jpa.entity.Report_;
import com.lanyuanxiaoyao.service.template.jpa.repository.EmployeeRepository;
import jakarta.annotation.Resource;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
import org.springframework.web.client.RestTemplate;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
@SpringBootApplication
@EnableFenix
@EnableJpaAuditing
public class TestApplication {
private static final Logger log = LoggerFactory.getLogger(TestApplication.class);
private static final String BASE_URL = "http://localhost:2490";
private static final RestTemplate REST_CLIENT = new RestTemplate();
private static final ObjectMapper MAPPER = new ObjectMapper();
@Resource
private EmployeeRepository employeeRepository;
public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}
@EventListener(ApplicationReadyEvent.class)
public void runTests() {
// 增
var cid1 = saveItem("company", "{\"name\": \"Apple\",\"members\": 10}").get("data").asLong();
var cid2 = saveItem("company", "{\"name\": \"Banana\",\"members\": 20}").get("data").asLong();
var cid3 = saveItem("company", "{\"name\": \"Cheery\",\"members\": 20}").get("data").asLong();
// 查
var companies = listItems("company");
Assert.isTrue(companies.at("/data/items").size() == 3, "数量错误");
Assert.isTrue(companies.at("/data/total").asLong() == 3, "返回数量错误");
// language=JSON
var companies2 = listItems("company", "{\n" +
" \"page\": {\n" +
" \"index\": 1,\n" +
" \"size\": 2\n" +
" }\n" +
"}");
Assert.isTrue(companies2.at("/data/items").size() == 2, "数量错误");
Assert.isTrue(companies2.at("/data/total").asLong() == 3, "返回数量错误");
// language=JSON
var companies3 = listItems("company", "{\n" +
" \"query\": {\n" +
" \"notNullEqual\": [\n" +
" \"name\"\n" +
" ],\n" +
" \"equal\": {\n" +
" \"name\": \"Apple\"\n" +
" },\n" +
" \"like\": {\n" +
" \"name\": \"Appl%\"\n" +
" },\n" +
" \"contain\": {\n" +
" \"name\": \"ple\"\n" +
" },\n" +
" \"startWith\": {\n" +
" \"name\": \"Appl\"\n" +
" },\n" +
" \"endWith\": {\n" +
" \"name\": \"le\"\n" +
" },\n" +
" \"less\": {\n" +
" \"members\": 50\n" +
" },\n" +
" \"greatEqual\": {\n" +
" \"members\": 0,\n" +
" \"createdTime\": \"2025-01-01 00:00:00\"\n" +
" },\n" +
" \"inside\": {\n" +
" \"name\": [\n" +
" \"Apple\",\n" +
" \"Banana\"\n" +
" ]\n" +
" },\n" +
" \"between\": {\n" +
" \"members\": {\n" +
" \"start\": 0,\n" +
" \"end\": 50\n" +
" }\n" +
" }\n" +
" },\n" +
" \"page\": {\n" +
" \"index\": 1,\n" +
" \"size\": 2\n" +
" }\n" +
"}");
Assert.isTrue(companies3.at("/data/items").size() == 1, "数量错误");
Assert.isTrue(companies3.at("/data/total").asLong() == 1, "返回数量错误");
var company1 = detailItem("company", cid1);
Assert.isTrue(cid1 == company1.at("/data/id").asLong(), "id错误");
Assert.isTrue("Apple".equals(company1.at("/data/name").asText()), "name错误");
// 改
var cid4 = saveItem("company", "{\"id\": %d, \"name\": \"Dog\"}".formatted(cid2)).get("data").asLong();
Assert.isTrue(cid2 == cid4, "id错误");
var company2 = detailItem("company", cid2);
Assert.isTrue("Dog".equals(company2.at("/data/name").asText()), "name错误");
// 删
removeItem("company", cid3);
Assert.isTrue(listItems("company").at("/data/items").size() == 2, "数量错误");
Assert.isTrue(listItems("company").at("/data/total").asLong() == 2, "返回数量错误");
log.info(listItems("company").toPrettyString());
var eid1 = saveItem("employee", "{\"name\": \"Tom\",\"age\": 18, \"companyId\": %d}".formatted(cid1)).get("data").asLong();
var eid2 = saveItem("employee", "{\"name\": \"Jerry\",\"age\": 18, \"companyId\": %d}".formatted(cid1)).get("data").asLong();
var eid3 = saveItem("employee", "{\"name\": \"Mike\",\"age\": 18, \"companyId\": %d}".formatted(cid2)).get("data").asLong();
var employees = listItems("employee");
Assert.isTrue(employees.at("/data/items").size() == 3, "数量错误");
Assert.isTrue(employees.at("/data/total").asLong() == 3, "返回数量错误");
var employee1 = detailItem("employee", eid1);
Assert.isTrue(eid1 == employee1.at("/data/id").asLong(), "id错误");
Assert.isTrue("Tom".equals(employee1.at("/data/name").asText()), "name错误");
Assert.isTrue(18 == employee1.at("/data/age").asInt(), "age错误");
System.exit(0);
}
@EventListener(ApplicationReadyEvent.class)
public void runSpecificationTests() {
// 增
var cid1 = saveItem("company", "{\"name\": \"Apple\",\"members\": 10}").get("data").asLong();
var cid2 = saveItem("company", "{\"name\": \"Banana\",\"members\": 20}").get("data").asLong();
var cid3 = saveItem("company", "{\"name\": \"Cheery\",\"members\": 20}").get("data").asLong();
var eid1 = saveItem("employee", "{\"name\": \"Tom\",\"age\": 18, \"companyId\": %d}".formatted(cid1)).get("data").asLong();
var eid2 = saveItem("employee", "{\"name\": \"Jerry\",\"age\": 18, \"companyId\": %d}".formatted(cid1)).get("data").asLong();
var eid3 = saveItem("employee", "{\"name\": \"Mike\",\"age\": 18, \"companyId\": %d}".formatted(cid2)).get("data").asLong();
var rid = saveItem("report", "{\"employeeId\": %d, \"score\": 56.38, \"level\": \"A\"}".formatted(eid1)).get("data").asLong();
var rid2 = saveItem("report", "{\"employeeId\": %d, \"score\": 78.98, \"level\": \"B\"}".formatted(eid2)).get("data").asLong();
log.debug(
"Results: {}",
employeeRepository.findAll(
builder -> builder
.andIsNotNull(Employee.Fields.name)
.andEquals(Employee.Fields.name, "Tom")
.andLike(Employee.Fields.name, "To%")
.andStartsWith(Employee.Fields.name, "To")
.andEndsWith(Employee.Fields.name, "om")
.andLessThan(Employee.Fields.age, 50)
.andGreaterThanEqual(Employee.Fields.age, 0)
.andIn(Employee.Fields.name, List.of("Tom", "Mike"))
.andBetween(Employee.Fields.age, 0, 50)
.build()
)
);
log.debug(
"Results: {}",
employeeRepository.findAll(
(root, query, builder) ->
builder.and(
builder.isNotNull(root.get(Employee_.name)),
builder.equal(root.get(Employee_.name), "Tom"),
builder.like(root.get(Employee_.name), "To%"),
builder.lessThan(root.get(Employee_.age), 50),
builder.greaterThanOrEqualTo(root.get(Employee_.age), 0),
builder.in(root.get(Employee_.NAME)).value(List.of("Tom", "Mike")),
builder.between(root.get(Employee_.age), 0, 50),
builder.isNotEmpty(root.get(Employee_.company).get(Company_.employees)),
builder.isMember(Company.Industry.MEDIA, root.get(Employee_.company).get(Company_.industries))
)
)
);
log.debug(
"Results: {}",
employeeRepository.findAll(
QEmployee.employee.name.isNotNull()
.and(QEmployee.employee.name.eq("Tom"))
.and(QEmployee.employee.name.like("To%"))
.and(QEmployee.employee.name.startsWith("To"))
.and(QEmployee.employee.name.endsWith("om"))
.and(QEmployee.employee.age.lt(50))
.and(QEmployee.employee.age.goe(0))
.and(QEmployee.employee.name.in("Tom", "Mike"))
.and(QEmployee.employee.age.between(0, 50))
.and(QEmployee.employee.company().employees.isNotEmpty())
.and(QEmployee.employee.company().employees.any().name.ne("Tom"))
.and(QEmployee.employee.company().industries.contains(Company.Industry.MEDIA))
.and(QEmployee.employee.connections.containsKey(Employee.ConnectionType.EMAIL))
)
);
log.debug(
"Results: {}",
employeeRepository.findAll(
(root, query, builder) -> {
var reportRoot = query.from(Report.class);
return builder.and(
builder.equal(root.get(Employee_.id), reportRoot.get(Report_.employeeId)),
builder.equal(reportRoot.get(Report_.level), Report.Level.A)
);
}
)
);
System.exit(0);
}
@EventListener(ApplicationReadyEvent.class)
public void runNativeQueryTests() {
// 增
var cid1 = saveItem("company", "{\"name\": \"Apple\",\"members\": 10}").get("data").asLong();
var cid2 = saveItem("company", "{\"name\": \"Banana\",\"members\": 20}").get("data").asLong();
var cid3 = saveItem("company", "{\"name\": \"Cheery\",\"members\": 20}").get("data").asLong();
var eid1 = saveItem("employee", "{\"name\": \"Tom\",\"age\": 18, \"companyId\": %d}".formatted(cid1)).get("data").asLong();
var eid2 = saveItem("employee", "{\"name\": \"Jerry\",\"age\": 18, \"companyId\": %d}".formatted(cid1)).get("data").asLong();
var eid3 = saveItem("employee", "{\"name\": \"Mike\",\"age\": 18, \"companyId\": %d}".formatted(cid2)).get("data").asLong();
var list = employeeRepository.findAllEmployeeWithCompanyName();
Assert.isTrue(list.size() == 3, "数量错误");
log.debug("Results: {}", list);
var list_native = employeeRepository.findAllEmployeeWithCompanyNameNative();
Assert.isTrue(list_native.size() == 3, "数量错误");
log.debug("Results: {}", list_native);
}
private HttpHeaders headers() {
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
private JsonNode saveItem(String path, String body) {
var response = REST_CLIENT.postForEntity(
"%s/%s/save".formatted(BASE_URL, path),
new HttpEntity<>(body, headers()),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private JsonNode listItems(String path) {
var response = REST_CLIENT.getForEntity(
"%s/%s/list".formatted(BASE_URL, path),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private JsonNode listItems(String path, String query) {
var response = REST_CLIENT.postForEntity(
"%s/%s/list".formatted(BASE_URL, path),
new HttpEntity<>(query, headers()),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private JsonNode detailItem(String path, Long id) {
var response = REST_CLIENT.getForEntity(
"%s/%s/detail/%d".formatted(BASE_URL, path, id),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private void removeItem(String path, Long id) {
var response = REST_CLIENT.getForEntity(
"%s/%s/remove/%d".formatted(BASE_URL, path, id),
Void.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
}
}

View File

@@ -0,0 +1,70 @@
package com.lanyuanxiaoyao.service.template.jpa.controller;
import com.lanyuanxiaoyao.service.template.jpa.entity.Company;
import com.lanyuanxiaoyao.service.template.jpa.service.CompanyService;
import java.time.LocalDateTime;
import java.util.function.Function;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("company")
public class CompanyController extends SimpleControllerSupport<Company, CompanyController.SaveItem, CompanyController.ListItem, CompanyController.DetailItem> {
public CompanyController(CompanyService service) {
super(service);
}
@Override
protected Function<SaveItem, Company> saveItemMapper() {
return item -> {
var company = new Company();
company.setId(item.id());
company.setName(item.name());
company.setMembers(item.members());
return company;
};
}
@Override
protected Function<Company, ListItem> listItemMapper() {
return company -> new ListItem(
company.getId(),
company.getName(),
company.getMembers()
);
}
@Override
protected Function<Company, DetailItem> detailItemMapper() {
return company -> new DetailItem(
company.getId(),
company.getName(),
company.getMembers(),
company.getCreatedTime(),
company.getModifiedTime()
);
}
public record SaveItem(
Long id,
String name,
Integer members
) {
}
public record ListItem(
Long id,
String name,
Integer members
) {
}
public record DetailItem(
Long id,
String name,
Integer members,
LocalDateTime createdTime,
LocalDateTime modifiedTime
) {
}
}

View File

@@ -0,0 +1,84 @@
package com.lanyuanxiaoyao.service.template.jpa.controller;
import com.lanyuanxiaoyao.service.template.jpa.entity.Employee;
import com.lanyuanxiaoyao.service.template.jpa.service.CompanyService;
import com.lanyuanxiaoyao.service.template.jpa.service.EmployeeService;
import java.time.LocalDateTime;
import java.util.function.Function;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("employee")
public class EmployeeController extends SimpleControllerSupport<Employee, EmployeeController.SaveItem, EmployeeController.ListItem, EmployeeController.DetailItem> {
private final CompanyService companyService;
public EmployeeController(EmployeeService service, CompanyService companyService) {
super(service);
this.companyService = companyService;
}
@Override
protected Function<SaveItem, Employee> saveItemMapper() {
return item -> {
var employee = new Employee();
employee.setId(item.id());
employee.setName(item.name());
employee.setAge(item.age());
employee.setRole(Employee.Role.USER);
employee.setCompany(companyService.detailOrThrow(item.companyId()));
return employee;
};
}
@Override
protected Function<Employee, ListItem> listItemMapper() {
return employee -> new ListItem(
employee.getId(),
employee.getName(),
employee.getAge(),
employee.getRole()
);
}
@Override
protected Function<Employee, DetailItem> detailItemMapper() {
return employee -> new DetailItem(
employee.getId(),
employee.getCompany().getId(),
employee.getName(),
employee.getAge(),
employee.getRole(),
employee.getCreatedTime(),
employee.getModifiedTime()
);
}
public record SaveItem(
Long id,
Long companyId,
String name,
Integer age,
Employee.Role role
) {
}
public record ListItem(
Long id,
String name,
Integer age,
Employee.Role role
) {
}
public record DetailItem(
Long id,
Long companyId,
String name,
Integer age,
Employee.Role role,
LocalDateTime createdTime,
LocalDateTime modifiedTime
) {
}
}

View File

@@ -0,0 +1,90 @@
package com.lanyuanxiaoyao.service.template.jpa.controller;
import com.lanyuanxiaoyao.service.template.jpa.entity.Report;
import com.lanyuanxiaoyao.service.template.jpa.service.EmployeeService;
import com.lanyuanxiaoyao.service.template.jpa.service.ReportService;
import java.time.LocalDateTime;
import java.util.function.Function;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("report")
public class ReportController extends SimpleControllerSupport<Report, ReportController.SaveItem, ReportController.ListItem, ReportController.DetailItem> {
private final EmployeeService employeeService;
public ReportController(ReportService service, EmployeeService employeeService) {
super(service);
this.employeeService = employeeService;
}
@Override
protected Function<SaveItem, Report> saveItemMapper() {
return item -> {
var report = new Report();
report.setId(item.id());
report.setScore(item.score());
report.setLevel(item.level());
report.setEmployeeId(item.employeeId());
return report;
};
}
@Override
protected Function<Report, ListItem> listItemMapper() {
return report -> {
var employee = employeeService.detailOrThrow(report.getEmployeeId());
return new ListItem(
report.getId(),
employee.getId(),
employee.getName(),
report.getScore(),
report.getLevel()
);
};
}
@Override
protected Function<Report, DetailItem> detailItemMapper() {
return report -> {
var employee = employeeService.detailOrThrow(report.getEmployeeId());
return new DetailItem(
report.getId(),
employee.getId(),
employee.getName(),
report.getScore(),
report.getLevel(),
report.getCreatedTime(),
report.getModifiedTime()
);
};
}
public record SaveItem(
Long id,
Double score,
Report.Level level,
Long employeeId
) {
}
public record ListItem(
Long id,
Long employeeId,
String employeeName,
Double score,
Report.Level level
) {
}
public record DetailItem(
Long id,
Long employeeId,
String employeeName,
Double score,
Report.Level level,
LocalDateTime createdTime,
LocalDateTime modifiedTime
) {
}
}

View File

@@ -0,0 +1,63 @@
package com.lanyuanxiaoyao.service.template.jpa.entity;
import jakarta.persistence.Column;
import jakarta.persistence.ConstraintMode;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.JoinTable;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.HashSet;
import java.util.Set;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.SoftDelete;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Setter
@Getter
@ToString(callSuper = true)
@FieldNameConstants
@Entity
@SoftDelete
@DynamicUpdate
@DynamicInsert
@EntityListeners(AuditingEntityListener.class)
@Table(comment = "企业")
public class Company extends SimpleEntity {
@Column(nullable = false, comment = "名称")
private String name;
@Column(nullable = false, comment = "成员数")
private Integer members;
@OneToMany(mappedBy = "company")
@ToString.Exclude
private Set<Employee> employees;
@ElementCollection
@JoinTable(foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT), inverseForeignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Set<Industry> industries = new HashSet<>();
public enum Industry {
TECHNOLOGY,
FINANCE,
MEDIA,
SERVICE,
GOVERNMENT,
EDUCATION,
HEALTHCARE,
CONSTRUCTION,
RETAIL,
OTHER,
}
}

View File

@@ -0,0 +1,67 @@
package com.lanyuanxiaoyao.service.template.jpa.entity;
import jakarta.persistence.Column;
import jakarta.persistence.ConstraintMode;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapKeyEnumerated;
import jakarta.persistence.Table;
import java.util.HashMap;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.SoftDelete;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Setter
@Getter
@ToString(callSuper = true)
@FieldNameConstants
@Entity
@SoftDelete
@DynamicUpdate
@DynamicInsert
@EntityListeners(AuditingEntityListener.class)
@Table(comment = "员工")
public class Employee extends SimpleEntity {
@Column(nullable = false, comment = "名称")
private String name;
@Column(nullable = false, comment = "年龄")
private Integer age;
@Column(nullable = false, comment = "角色")
@Enumerated(EnumType.STRING)
private Role role;
@ManyToOne
@JoinColumn(nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
@ToString.Exclude
private Company company;
@ElementCollection
@JoinTable(foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT), inverseForeignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
@MapKeyEnumerated(EnumType.STRING)
@Column(nullable = false)
private Map<ConnectionType, String> connections = new HashMap<>();
public enum Role {
USER,
ADMIN,
}
public enum ConnectionType {
EMAIL,
PHONE,
ADDRESS,
}
}

View File

@@ -0,0 +1,41 @@
package com.lanyuanxiaoyao.service.template.jpa.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.SoftDelete;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Setter
@Getter
@ToString(callSuper = true)
@FieldNameConstants
@Entity
@SoftDelete
@DynamicUpdate
@DynamicInsert
@EntityListeners(AuditingEntityListener.class)
@Table(comment = "报告")
public class Report extends SimpleEntity {
@Column(nullable = false, comment = "分数")
private Double score = 0.0;
@Column(nullable = false, comment = "等级")
@Enumerated(EnumType.STRING)
private Level level;
@Column(nullable = false, comment = "员工 ID")
private Long employeeId;
public enum Level {
A, B, C, D, E
}
}

View File

@@ -0,0 +1,9 @@
package com.lanyuanxiaoyao.service.template.jpa.entity.vo;
public record EmployeeWithCompanyName(
String name,
String companyName,
Integer age,
String role
) {
}

View File

@@ -0,0 +1,8 @@
package com.lanyuanxiaoyao.service.template.jpa.repository;
import com.lanyuanxiaoyao.service.template.jpa.entity.Company;
import org.springframework.stereotype.Repository;
@Repository
public interface CompanyRepository extends SimpleRepository<Company> {
}

View File

@@ -0,0 +1,24 @@
package com.lanyuanxiaoyao.service.template.jpa.repository;
import com.lanyuanxiaoyao.service.template.jpa.entity.Employee;
import com.lanyuanxiaoyao.service.template.jpa.entity.vo.EmployeeWithCompanyName;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
@SuppressWarnings("NullableProblems")
@Repository
public interface EmployeeRepository extends SimpleRepository<Employee> {
@EntityGraph(attributePaths = {"company"})
@Override
Optional<Employee> findOne(Specification<Employee> specification);
@Query(value = "select e.name, c.name, e.age, e.role from employee e, company c where e.company_id = c.id", nativeQuery = true)
List<EmployeeWithCompanyName> findAllEmployeeWithCompanyNameNative();
@Query("select new com.lanyuanxiaoyao.service.template.jpa.entity.vo.EmployeeWithCompanyName(employee.name, employee.company.name, employee.age, cast(employee.role as string)) from Employee employee")
List<EmployeeWithCompanyName> findAllEmployeeWithCompanyName();
}

View File

@@ -0,0 +1,8 @@
package com.lanyuanxiaoyao.service.template.jpa.repository;
import com.lanyuanxiaoyao.service.template.jpa.entity.Report;
import org.springframework.stereotype.Repository;
@Repository
public interface ReportRepository extends SimpleRepository<Report> {
}

View File

@@ -0,0 +1,12 @@
package com.lanyuanxiaoyao.service.template.jpa.service;
import com.lanyuanxiaoyao.service.template.jpa.entity.Company;
import com.lanyuanxiaoyao.service.template.jpa.repository.CompanyRepository;
import org.springframework.stereotype.Service;
@Service
public class CompanyService extends SimpleServiceSupport<Company> {
public CompanyService(CompanyRepository repository) {
super(repository);
}
}

View File

@@ -0,0 +1,12 @@
package com.lanyuanxiaoyao.service.template.jpa.service;
import com.lanyuanxiaoyao.service.template.jpa.entity.Employee;
import com.lanyuanxiaoyao.service.template.jpa.repository.EmployeeRepository;
import org.springframework.stereotype.Service;
@Service
public class EmployeeService extends SimpleServiceSupport<Employee> {
public EmployeeService(EmployeeRepository repository) {
super(repository);
}
}

View File

@@ -0,0 +1,12 @@
package com.lanyuanxiaoyao.service.template.jpa.service;
import com.lanyuanxiaoyao.service.template.jpa.entity.Report;
import com.lanyuanxiaoyao.service.template.jpa.repository.ReportRepository;
import org.springframework.stereotype.Service;
@Service
public class ReportService extends SimpleServiceSupport<Report> {
public ReportService(ReportRepository repository) {
super(repository);
}
}

View File

@@ -0,0 +1,23 @@
server:
port: 2490
spring:
application:
name: Test
datasource:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
username: test
password: test
driver-class-name: org.h2.Driver
jpa:
generate-ddl: true
fenix:
print-banner: false
decorator:
datasource:
p6spy:
multiline: false
exclude-categories:
- commit
- result
- resultset
log-format: "%(category)|%(executionTime)|%(sqlSingleLine)"

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lanyuanxiaoyao</groupId>
<artifactId>spring-boot-service-template</artifactId>
<version>1.1.0-SNAPSHOT</version>
</parent>
<artifactId>spring-boot-service-template-database-xbatis</artifactId>
<dependencies>
<dependency>
<groupId>com.lanyuanxiaoyao</groupId>
<artifactId>spring-boot-service-template-database-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>cn.xbatis</groupId>
<artifactId>xbatis-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,16 @@
package com.lanyuanxiaoyao.service.template.xbatis.configuration;
import cn.xbatis.core.incrementer.GeneratorFactory;
import cn.xbatis.core.mybatis.mapper.BasicMapper;
import com.lanyuanxiaoyao.service.template.xbatis.entity.SnowflakeIdGenerator;
import com.lanyuanxiaoyao.service.template.xbatis.mapper.MybatisBasicMapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan(basePackageClasses = MybatisBasicMapper.class, markerInterface = BasicMapper.class)
public class MybatisConfiguration {
static {
GeneratorFactory.register("snowflake", new SnowflakeIdGenerator());
}
}

View File

@@ -0,0 +1,214 @@
package com.lanyuanxiaoyao.service.template.xbatis.controller;
import com.lanyuanxiaoyao.service.template.common.helper.ObjectHelper;
import com.lanyuanxiaoyao.service.template.database.common.controller.SimpleController;
import com.lanyuanxiaoyao.service.template.database.common.entity.GlobalResponse;
import com.lanyuanxiaoyao.service.template.database.common.entity.Query;
import com.lanyuanxiaoyao.service.template.xbatis.entity.SimpleEntity;
import com.lanyuanxiaoyao.service.template.xbatis.service.SimpleServiceSupport;
import java.util.function.Function;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* 简单控制器支持类提供基础的CRUD操作实现
* <p>
* 该类实现了基本的增删改查功能,通过泛型支持不同类型的数据转换。
* 子类需要实现对应的Mapper函数来完成实体类与传输对象之间的转换。
* </p>
*
* <h3>设计特点</h3>
* <ul>
* <li>泛型设计,支持任意实体类型和数据转换</li>
* <li>统一的异常处理和事务管理</li>
* <li>支持条件查询、分页查询和详情查询</li>
* <li>提供抽象的Mapper方法便于子类实现数据转换逻辑</li>
* </ul>
*
* <h3>使用说明</h3>
* <p>子类需要实现以下抽象方法:</p>
* <ul>
* <li>saveItemMapper(): 保存项到实体的转换函数</li>
* <li>listItemMapper(): 实体到列表项的转换函数</li>
* <li>detailItemMapper(): 实体到详情项的转换函数</li>
* </ul>
*
* @param <ENTITY> 实体类型必须继承SimpleEntity
* @param <SAVE_ITEM> 保存项类型
* @param <LIST_ITEM> 列表项类型
* @param <DETAIL_ITEM> 详情项类型
*/
@Slf4j
public abstract class SimpleControllerSupport<ENTITY extends SimpleEntity, SAVE_ITEM, LIST_ITEM, DETAIL_ITEM> implements SimpleController<SAVE_ITEM, LIST_ITEM, DETAIL_ITEM> {
protected final SimpleServiceSupport<ENTITY> service;
/**
* 构造函数
*
* @param service 简单服务支持类实例
*/
public SimpleControllerSupport(SimpleServiceSupport<ENTITY> service) {
this.service = service;
}
/**
* 保存实体对象
* <p>
* 将保存项转换为实体对象后保存返回保存后的实体ID。
* 支持新增和更新操作,通过事务保证数据一致性。
* </p>
*
* @param item 需要保存的项
* @return 返回保存后的实体ID响应对象格式{status: 0, message: "OK", data: 实体ID}
* @throws Exception 保存过程中可能抛出的异常
*/
@Transactional(rollbackFor = Throwable.class)
@PostMapping(SAVE)
@Override
public GlobalResponse<Long> save(@RequestBody SAVE_ITEM item) throws Exception {
var mapper = saveItemMapper();
return GlobalResponse.responseSuccess(service.save(mapper.apply(item)));
}
/**
* 获取所有实体列表
* <p>
* 查询所有记录,不带任何过滤条件,返回分页格式的数据。
* 将实体对象转换为列表项对象后返回。
* </p>
*
* @return 返回实体列表响应对象,格式:{status: 0, message: "OK", data: {items: [...], total: total}}
* @throws Exception 查询过程中可能抛出的异常
*/
@Transactional(readOnly = true)
@GetMapping(LIST)
@Override
public GlobalResponse<GlobalResponse.ListItem<LIST_ITEM>> list() throws Exception {
var mapper = listItemMapper();
var result = service.list();
return GlobalResponse.responseListData(
result
.stream()
.map(entity -> {
try {
return mapper.apply(entity);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList(),
result.size()
);
}
/**
* 根据查询条件获取实体列表
* <p>
* 支持复杂的查询条件、排序和分页,返回符合条件的数据。
* 将实体对象转换为列表项对象后返回。
* </p>
*
* @param query 查询条件对象,包含过滤条件、排序规则和分页信息
* @return 返回符合条件的实体列表响应对象,格式:{status: 0, message: "OK", data: {items: [...], total: total}}
* @throws Exception 查询过程中可能抛出的异常
*/
@Transactional(readOnly = true)
@PostMapping(LIST)
@Override
public GlobalResponse<GlobalResponse.ListItem<LIST_ITEM>> list(@RequestBody Query query) throws Exception {
if (ObjectHelper.isNull(query)) {
return GlobalResponse.responseListData();
}
var mapper = listItemMapper();
var result = service.list(query);
return GlobalResponse.responseListData(
result.items()
.stream()
.map(entity -> {
try {
return mapper.apply(entity);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList(),
result.total()
);
}
/**
* 根据ID获取实体详情
* <p>
* 根据主键ID查询单条记录的详细信息转换为详情项对象后返回。
* 如果记录不存在则抛出异常。
* </p>
*
* @param id 实体主键ID
* @return 返回实体详情响应对象,格式:{status: 0, message: "OK", data: 详情数据}
* @throws Exception 查询过程中可能抛出的异常
*/
@Transactional(readOnly = true)
@GetMapping(DETAIL)
@Override
public GlobalResponse<DETAIL_ITEM> detail(@PathVariable("id") Long id) throws Exception {
var mapper = detailItemMapper();
return GlobalResponse.responseSuccess(mapper.apply(service.detailOrThrow(id)));
}
/**
* 根据ID删除实体对象
* <p>
* 根据主键ID删除指定的记录执行成功后返回成功响应。
* 通过事务保证删除操作的一致性。
* </p>
*
* @param id 需要删除的实体主键ID
* @return 返回删除结果响应对象,格式:{status: 0, message: "OK", data: null}
* @throws Exception 删除过程中可能抛出的异常
*/
@Transactional(rollbackFor = Throwable.class)
@GetMapping(REMOVE)
@Override
public GlobalResponse<Object> remove(@PathVariable("id") Long id) throws Exception {
service.remove(id);
return GlobalResponse.responseSuccess();
}
/**
* 保存项映射器,将保存项转换为实体对象
* <p>
* 子类需要实现此方法,定义保存项到实体的转换逻辑。
* </p>
*
* @return Function<SAVE_ITEM, ENTITY> 保存项到实体的转换函数
*/
protected abstract Function<SAVE_ITEM, ENTITY> saveItemMapper();
/**
* 列表项映射器,将实体对象转换为列表项
* <p>
* 子类需要实现此方法,定义实体到列表项的转换逻辑。
* </p>
*
* @return Function<ENTITY, LIST_ITEM> 实体到列表项的转换函数
*/
protected abstract Function<ENTITY, LIST_ITEM> listItemMapper();
/**
* 详情项映射器,将实体对象转换为详情项
* <p>
* 子类需要实现此方法,定义实体到详情项的转换逻辑。
* </p>
*
* @return Function<ENTITY, DETAIL_ITEM> 实体到详情项的转换函数
*/
protected abstract Function<ENTITY, DETAIL_ITEM> detailItemMapper();
public interface Mapper<S, T> {
T map(S source) throws Exception;
}
}

View File

@@ -0,0 +1,17 @@
package com.lanyuanxiaoyao.service.template.xbatis.entity;
import cn.xbatis.db.IdAutoType;
import cn.xbatis.db.annotations.TableId;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
@Getter
@Setter
@ToString
@FieldNameConstants
public class IdOnlyEntity {
@TableId(value = IdAutoType.GENERATOR, generator = "snowflake")
private Long id;
}

View File

@@ -0,0 +1,19 @@
package com.lanyuanxiaoyao.service.template.xbatis.entity;
import cn.xbatis.db.annotations.TableField;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
@Getter
@Setter
@ToString(callSuper = true)
@FieldNameConstants
public class SimpleEntity extends IdOnlyEntity {
@TableField(defaultValue = "{NOW}", defaultValueFillAlways = true)
private LocalDateTime createdTime;
@TableField(defaultValue = "{NOW}", defaultValueFillAlways = true, updateDefaultValue = "{NOW}", updateDefaultValueFillAlways = true)
private LocalDateTime modifiedTime;
}

View File

@@ -0,0 +1,11 @@
package com.lanyuanxiaoyao.service.template.xbatis.entity;
import cn.xbatis.core.incrementer.Generator;
import com.lanyuanxiaoyao.service.template.database.common.helper.SnowflakeHelper;
public class SnowflakeIdGenerator implements Generator<Long> {
@Override
public Long nextId(Class<?> entity) {
return SnowflakeHelper.next();
}
}

View File

@@ -0,0 +1,6 @@
package com.lanyuanxiaoyao.service.template.xbatis.mapper;
import cn.xbatis.core.mybatis.mapper.BasicMapper;
public interface MybatisBasicMapper extends BasicMapper {
}

View File

@@ -0,0 +1,250 @@
package com.lanyuanxiaoyao.service.template.xbatis.service;
import cn.xbatis.core.mybatis.mapper.context.Pager;
import cn.xbatis.core.sql.MybatisCmdFactory;
import cn.xbatis.core.sql.executor.chain.QueryChain;
import com.lanyuanxiaoyao.service.template.common.helper.ObjectHelper;
import com.lanyuanxiaoyao.service.template.database.common.entity.Page;
import com.lanyuanxiaoyao.service.template.database.common.entity.Query;
import com.lanyuanxiaoyao.service.template.database.common.exception.IdNotFoundException;
import com.lanyuanxiaoyao.service.template.database.common.service.QueryParser;
import com.lanyuanxiaoyao.service.template.database.common.service.SimpleService;
import com.lanyuanxiaoyao.service.template.xbatis.entity.SimpleEntity;
import com.lanyuanxiaoyao.service.template.xbatis.mapper.MybatisBasicMapper;
import db.sql.api.cmd.LikeMode;
import db.sql.api.impl.cmd.basic.OrderByDirection;
import db.sql.api.impl.cmd.struct.Where;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.mapstruct.Named;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
public abstract class SimpleServiceSupport<ENTITY extends SimpleEntity> implements SimpleService<ENTITY> {
private static final int DEFAULT_PAGE_INDEX = 1;
private static final int DEFAULT_PAGE_SIZE = 10;
protected final MybatisBasicMapper mapper;
private final Class<ENTITY> target;
public SimpleServiceSupport(Class<ENTITY> target, MybatisBasicMapper mapper) {
this.target = target;
this.mapper = mapper;
}
@Transactional(rollbackFor = Throwable.class)
@Override
public Long save(ENTITY entity) {
return (long) mapper.save(entity);
}
@Transactional(rollbackFor = Throwable.class)
@Override
public void save(Iterable<ENTITY> entities) {
mapper.save(entities);
}
@Override
public Long count() {
return (long) mapper.countAll(target);
}
@Override
public List<ENTITY> list() {
return mapper.listAll(target);
}
@Override
public List<ENTITY> list(Set<Long> ids) {
return mapper.listByIds(target, ids);
}
protected void commonPredicates(Where where) {
}
@Override
public Page<ENTITY> list(Query query) {
var chain = QueryChain.of(mapper, target);
var factory = chain.$();
var paging = Pager.<ENTITY>of(DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE);
if (ObjectHelper.isNotNull(query.page())) {
var index = Math.max(ObjectHelper.defaultIfNull(query.page().index(), DEFAULT_PAGE_INDEX), 1);
var size = Math.max(ObjectHelper.defaultIfNull(query.page().size(), DEFAULT_PAGE_SIZE), 1);
paging = Pager.of(index, size);
}
chain.paging(paging);
if (ObjectHelper.isNotEmpty(query.sort())) {
query.sort().forEach(sort -> chain.orderBy(OrderByDirection.valueOf(sort.direction().name()), sort.column()));
}
var where = chain.where();
commonPredicates(where);
new XBatisQueryParser<>(query.query(), where, target, factory).build();
return new Page<>(chain.list(), chain.count());
}
private Optional<ENTITY> detailOptional(Long id) {
if (ObjectHelper.isNull(id)) {
return Optional.empty();
}
return Optional.ofNullable(mapper.getById(target, id));
}
@Named("detail")
@Override
public ENTITY detail(Long id) {
return detailOptional(id).orElse(null);
}
@Named("detailOrThrow")
@Override
public ENTITY detailOrThrow(Long id) {
return detailOptional(id).orElseThrow(() -> new IdNotFoundException(id));
}
@Transactional(rollbackFor = Throwable.class)
@Override
public void remove(Long id) {
mapper.deleteById(target, id);
}
@Transactional(rollbackFor = Throwable.class)
@Override
public void remove(Set<Long> ids) {
mapper.deleteByIds(target, ids);
}
private static final class XBatisQueryParser<ENTITY> extends QueryParser<Where> {
private final Class<ENTITY> target;
private final MybatisCmdFactory factory;
private XBatisQueryParser(Query.Queryable queryable, Where where, Class<ENTITY> target, MybatisCmdFactory factory) {
super(queryable, where);
this.target = target;
this.factory = factory;
}
@Override
protected void nullEqual(Query.Queryable queryable, Where where) {
queryable.nullEqual().forEach(column -> where.isNull(factory.field(target, column)));
}
@Override
protected void notNullEqual(Query.Queryable queryable, Where where) {
queryable.notNullEqual().forEach(column -> where.isNotNull(factory.field(target, column)));
}
@Override
protected void empty(Query.Queryable queryable, Where where) {
throw new UnsupportedOperationException();
}
@Override
protected void notEmpty(Query.Queryable queryable, Where where) {
throw new UnsupportedOperationException();
}
@Override
protected void equal(Query.Queryable queryable, Where where) {
queryable.equal().forEach((column, value) -> where.eq(factory.field(target, column), value));
}
@Override
protected void notEqual(Query.Queryable queryable, Where where) {
queryable.notEqual().forEach((column, value) -> where.ne(factory.field(target, column), value));
}
@Override
protected void like(Query.Queryable queryable, Where where) {
queryable.like().forEach((column, value) -> where.like(LikeMode.NONE, factory.field(target, column), value));
}
@Override
protected void notLike(Query.Queryable queryable, Where where) {
queryable.notLike().forEach((column, value) -> where.notLike(LikeMode.NONE, factory.field(target, column), value));
}
@Override
protected void contain(Query.Queryable queryable, Where where) {
queryable.contain().forEach((column, value) -> where.like(factory.field(target, column), value));
}
@Override
protected void notContain(Query.Queryable queryable, Where where) {
queryable.notContain().forEach((column, value) -> where.notLike(factory.field(target, column), value));
}
@Override
protected void startWith(Query.Queryable queryable, Where where) {
queryable.startWith().forEach((column, value) -> where.like(LikeMode.LEFT, factory.field(target, column), value));
}
@Override
protected void notStartWith(Query.Queryable queryable, Where where) {
queryable.notStartWith().forEach((column, value) -> where.notLike(LikeMode.LEFT, factory.field(target, column), value));
}
@Override
protected void endWith(Query.Queryable queryable, Where where) {
queryable.endWith().forEach((column, value) -> where.like(LikeMode.RIGHT, factory.field(target, column), value));
}
@Override
protected void notEndWith(Query.Queryable queryable, Where where) {
queryable.notEndWith().forEach((column, value) -> where.notLike(LikeMode.RIGHT, factory.field(target, column), value));
}
@Override
protected void great(Query.Queryable queryable, Where where) {
queryable.great().forEach((column, value) -> where.gt(factory.field(target, column), value));
}
@Override
protected void less(Query.Queryable queryable, Where where) {
queryable.less().forEach((column, value) -> where.lt(factory.field(target, column), value));
}
@Override
protected void greatEqual(Query.Queryable queryable, Where where) {
queryable.greatEqual().forEach((column, value) -> where.gte(factory.field(target, column), value));
}
@Override
protected void lessEqual(Query.Queryable queryable, Where where) {
queryable.lessEqual().forEach((column, value) -> where.lte(factory.field(target, column), value));
}
@Override
protected void inside(Query.Queryable queryable, Where where) {
queryable.inside()
.entrySet()
.stream()
.filter(entry -> ObjectHelper.isNotEmpty(entry.getValue()))
.forEach(entry -> where.in(factory.field(target, entry.getKey()), entry.getValue()));
}
@Override
protected void notInside(Query.Queryable queryable, Where where) {
queryable.notInside()
.entrySet()
.stream()
.filter(entry -> ObjectHelper.isNotEmpty(entry.getValue()))
.forEach(entry -> where.notIn(factory.field(target, entry.getKey()), entry.getValue()));
}
@Override
protected void between(Query.Queryable queryable, Where where) {
queryable.between().forEach((column, value) -> where.between(factory.field(target, column), value.start(), value.end()));
}
@Override
protected void notBetween(Query.Queryable queryable, Where where) {
queryable.notBetween().forEach((column, value) -> where.notBetween(factory.field(target, column), value.start(), value.end()));
}
}
}

View File

@@ -0,0 +1,17 @@
create table if not exists Company
(
id bigint primary key,
name varchar(255) not null,
members int not null,
created_time timestamp not null,
modified_time timestamp not null
);
create table if not exists Employee
(
id bigint primary key,
name varchar(255) not null,
age int not null,
created_time timestamp not null,
modified_time timestamp not null
);

View File

@@ -0,0 +1,173 @@
package com.lanyuanxiaoyao.service.template.xbatis;
import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
import org.springframework.web.client.RestTemplate;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
@Slf4j
@MapperScan("com.lanyuanxiaoyao.service.template.xbatis.mapper")
@SpringBootApplication
public class TestApplication {
private static final String BASE_URL = "http://localhost:2490";
private static final RestTemplate REST_CLIENT = new RestTemplate();
private static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}
@EventListener(ApplicationReadyEvent.class)
public void runTests() {
// 增
var cid1 = saveItem("company", "{\"name\": \"Apple\",\"members\": 10}").get("data").asLong();
var cid2 = saveItem("company", "{\"name\": \"Banana\",\"members\": 20}").get("data").asLong();
var cid3 = saveItem("company", "{\"name\": \"Cheery\",\"members\": 20}").get("data").asLong();
// 查
var companies = listItems("company");
Assert.isTrue(companies.at("/data/items").size() == 3, "数量错误");
Assert.isTrue(companies.at("/data/total").asLong() == 3, "返回数量错误");
// language=JSON
var companies2 = listItems("company", "{\n" +
" \"page\": {\n" +
" \"index\": 1,\n" +
" \"size\": 2\n" +
" }\n" +
"}");
Assert.isTrue(companies2.at("/data/items").size() == 2, "数量错误");
Assert.isTrue(companies2.at("/data/total").asLong() == 3, "返回数量错误");
// language=JSON
var companies3 = listItems("company", "{\n" +
" \"query\": {\n" +
" \"notNullEqual\": [\n" +
" \"name\"\n" +
" ],\n" +
" \"equal\": {\n" +
" \"name\": \"Apple\"\n" +
" },\n" +
" \"like\": {\n" +
" \"name\": \"Appl%\"\n" +
" },\n" +
" \"contain\": {\n" +
" \"name\": \"ple\"\n" +
" },\n" +
" \"startWith\": {\n" +
" \"name\": \"Appl\"\n" +
" },\n" +
" \"endWith\": {\n" +
" \"name\": \"le\"\n" +
" },\n" +
" \"less\": {\n" +
" \"members\": 50\n" +
" },\n" +
" \"greatEqual\": {\n" +
" \"members\": 0,\n" +
" \"createdTime\": \"2025-01-01 00:00:00\"\n" +
" },\n" +
" \"inside\": {\n" +
" \"name\": [\n" +
" \"Apple\",\n" +
" \"Banana\"\n" +
" ]\n" +
" },\n" +
" \"between\": {\n" +
" \"members\": {\n" +
" \"start\": 0,\n" +
" \"end\": 50\n" +
" }\n" +
" }\n" +
" },\n" +
" \"page\": {\n" +
" \"index\": 1,\n" +
" \"size\": 2\n" +
" }\n" +
"}");
Assert.isTrue(companies3.at("/data/items").size() == 1, "数量错误");
Assert.isTrue(companies3.at("/data/total").asLong() == 1, "返回数量错误");
var company1 = detailItem("company", cid1);
Assert.isTrue(cid1 == company1.at("/data/id").asLong(), "id错误");
Assert.isTrue("Apple".equals(company1.at("/data/name").asText()), "name错误");
// 改
var cid4 = saveItem("company", "{\"id\": %d, \"name\": \"Dog\"}".formatted(cid2)).get("data").asLong();
Assert.isTrue(cid2 == cid4, "id错误");
var company2 = detailItem("company", cid2);
Assert.isTrue("Dog".equals(company2.at("/data/name").asText()), "name错误");
// 删
removeItem("company", cid3);
Assert.isTrue(listItems("company").at("/data/items").size() == 2, "数量错误");
Assert.isTrue(listItems("company").at("/data/total").asLong() == 2, "返回数量错误");
log.info(listItems("company").toPrettyString());
System.exit(0);
}
private HttpHeaders headers() {
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
private JsonNode saveItem(String path, String body) {
var response = REST_CLIENT.postForEntity(
"%s/%s/save".formatted(BASE_URL, path),
new HttpEntity<>(body, headers()),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private JsonNode listItems(String path) {
var response = REST_CLIENT.getForEntity(
"%s/%s/list".formatted(BASE_URL, path),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private JsonNode listItems(String path, String query) {
var response = REST_CLIENT.postForEntity(
"%s/%s/list".formatted(BASE_URL, path),
new HttpEntity<>(query, headers()),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private JsonNode detailItem(String path, Long id) {
var response = REST_CLIENT.getForEntity(
"%s/%s/detail/%d".formatted(BASE_URL, path, id),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private void removeItem(String path, Long id) {
var response = REST_CLIENT.getForEntity(
"%s/%s/remove/%d".formatted(BASE_URL, path, id),
Void.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
}
}

View File

@@ -0,0 +1,70 @@
package com.lanyuanxiaoyao.service.template.xbatis.controller;
import com.lanyuanxiaoyao.service.template.xbatis.entity.Company;
import com.lanyuanxiaoyao.service.template.xbatis.service.CompanyService;
import java.time.LocalDateTime;
import java.util.function.Function;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("company")
public class CompanyController extends SimpleControllerSupport<Company, CompanyController.SaveItem, CompanyController.ListItem, CompanyController.DetailItem> {
public CompanyController(CompanyService service) {
super(service);
}
@Override
protected Function<SaveItem, Company> saveItemMapper() {
return item -> {
var company = new Company();
company.setId(item.id());
company.setName(item.name());
company.setMembers(item.members());
return company;
};
}
@Override
protected Function<Company, ListItem> listItemMapper() {
return company -> new ListItem(
company.getId(),
company.getName(),
company.getMembers()
);
}
@Override
protected Function<Company, DetailItem> detailItemMapper() {
return company -> new DetailItem(
company.getId(),
company.getName(),
company.getMembers(),
company.getCreatedTime(),
company.getModifiedTime()
);
}
public record SaveItem(
Long id,
String name,
Integer members
) {
}
public record ListItem(
Long id,
String name,
Integer members
) {
}
public record DetailItem(
Long id,
String name,
Integer members,
LocalDateTime createdTime,
LocalDateTime modifiedTime
) {
}
}

View File

@@ -0,0 +1,17 @@
package com.lanyuanxiaoyao.service.template.xbatis.entity;
import cn.xbatis.db.annotations.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
@Getter
@Setter
@ToString(callSuper = true)
@FieldNameConstants
@Table
public class Company extends SimpleEntity {
private String name;
private Integer members;
}

View File

@@ -0,0 +1,17 @@
package com.lanyuanxiaoyao.service.template.xbatis.entity;
import cn.xbatis.db.annotations.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
@Getter
@Setter
@ToString(callSuper = true)
@FieldNameConstants
@Table
public class Employee extends SimpleEntity {
private String name;
private Integer age;
}

View File

@@ -0,0 +1,12 @@
package com.lanyuanxiaoyao.service.template.xbatis.service;
import com.lanyuanxiaoyao.service.template.xbatis.entity.Company;
import com.lanyuanxiaoyao.service.template.xbatis.mapper.MybatisBasicMapper;
import org.springframework.stereotype.Service;
@Service
public class CompanyService extends SimpleServiceSupport<Company> {
public CompanyService(MybatisBasicMapper mapper) {
super(Company.class, mapper);
}
}

View File

@@ -0,0 +1,12 @@
package com.lanyuanxiaoyao.service.template.xbatis.service;
import com.lanyuanxiaoyao.service.template.xbatis.entity.Employee;
import com.lanyuanxiaoyao.service.template.xbatis.mapper.MybatisBasicMapper;
import org.springframework.stereotype.Service;
@Service
public class EmployeeService extends SimpleServiceSupport<Employee> {
public EmployeeService(MybatisBasicMapper mapper) {
super(Employee.class, mapper);
}
}

View File

@@ -0,0 +1,22 @@
server:
port: 2490
spring:
application:
name: Test
datasource:
url: "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL;DATABASE_TO_LOWER=TRUE;INIT=runscript from '/Users/lanyuanxiaoyao/Project/IdeaProjects/spring-boot-service-template/spring-boot-service-template-database/spring-boot-service-template-database-xbatis/src/test/initial.sql'"
username: test
password: test
driver-class-name: org.h2.Driver
mybatis:
configuration:
banner: false
decorator:
datasource:
p6spy:
multiline: false
exclude-categories:
- commit
- result
- resultset
log-format: "%(category)|%(executionTime)|%(sqlSingleLine)"