diff --git a/airpower-core/pom.xml b/airpower-core/pom.xml index 4f3393d2801864db09bd7d6ce650ec91d67cb5e1..6476c0dc6d42cacaa0fdbf1383b14dbdcd82e218 100644 --- a/airpower-core/pom.xml +++ b/airpower-core/pom.xml @@ -5,10 +5,10 @@ cn.hamm airpower - 2.1.0 + 2.1.1 airpower-core - 2.1.0 + 2.1.1 airpower-core AirPower is a fast backend development tool based on SpringBoot3 and JPA. It's the core of AirPower. diff --git a/airpower-core/src/main/java/cn/hamm/airpower/annotation/ExcelColumn.java b/airpower-core/src/main/java/cn/hamm/airpower/annotation/ExcelColumn.java new file mode 100644 index 0000000000000000000000000000000000000000..5b1a35a4609219be6be50323bb7b0a71aea1e75a --- /dev/null +++ b/airpower-core/src/main/java/cn/hamm/airpower/annotation/ExcelColumn.java @@ -0,0 +1,48 @@ +package cn.hamm.airpower.annotation; + +import cn.hamm.airpower.validate.dictionary.Dictionary; +import java.lang.annotation.*; + +/** + *

Excel导出列

+ * + * @author Hamm.cn + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface ExcelColumn { + /** + *

类型

+ */ + Type value() default Type.TEXT; + + enum Type { + /** + *

普通文本

+ */ + TEXT, + + /** + *

时间日期

+ */ + DATETIME, + + /** + *

数字

+ */ + NUMBER, + + /** + *

字典

+ * @apiNote 请确保同时标记了 @{@link Dictionary} + */ + DICTIONARY, + + /** + *

布尔值

+ */ + BOOLEAN + } +} diff --git a/airpower-core/src/main/java/cn/hamm/airpower/config/Constant.java b/airpower-core/src/main/java/cn/hamm/airpower/config/Constant.java index ded1a3eabfbfa6be4f0f011527d47fbd67c05775..8babbe707092514c24e04c3f92e580a585612186 100644 --- a/airpower-core/src/main/java/cn/hamm/airpower/config/Constant.java +++ b/airpower-core/src/main/java/cn/hamm/airpower/config/Constant.java @@ -237,4 +237,24 @@ public class Constant { *

毫秒转秒

*/ public static final int MILLISECONDS_PER_SECOND = 1000; + + /** + *

换行

+ */ + public static final String LINE_BREAK = "\n"; + + /** + *

TAB

+ */ + public static final String TAB = "\t"; + + /** + *

+ */ + public static final String YES = "是"; + + /** + *

+ */ + public static final String NO = "否"; } diff --git a/airpower-core/src/main/java/cn/hamm/airpower/config/ServiceConfig.java b/airpower-core/src/main/java/cn/hamm/airpower/config/ServiceConfig.java index 802e9c711ebdb88bc8d01a1681ba4f9c0dab25bc..f281ff5c9ea88c9bdc6985d76813c6169805c2a2 100644 --- a/airpower-core/src/main/java/cn/hamm/airpower/config/ServiceConfig.java +++ b/airpower-core/src/main/java/cn/hamm/airpower/config/ServiceConfig.java @@ -83,6 +83,13 @@ public class ServiceConfig { */ private String tenantHeader = "tenant-code"; + /** + *

导出文件的目录

+ * + * @apiNote 请不要使用 / 结尾 + */ + private String exportFilePath = ""; + /** *

是否开启调试模式

* diff --git a/airpower-core/src/main/java/cn/hamm/airpower/enums/DateTimeFormatter.java b/airpower-core/src/main/java/cn/hamm/airpower/enums/DateTimeFormatter.java new file mode 100644 index 0000000000000000000000000000000000000000..0049772d749d1e2505ec4b555d866b4d3ebaabf0 --- /dev/null +++ b/airpower-core/src/main/java/cn/hamm/airpower/enums/DateTimeFormatter.java @@ -0,0 +1,66 @@ +package cn.hamm.airpower.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + *

格式化模板

+ * + * @author Hamm.cn + */ +@Getter +@AllArgsConstructor +public enum DateTimeFormatter { + /** + *

+ */ + YEAR("yyyy"), + + /** + *

+ */ + MONTH("MM"), + + /** + *

+ */ + DAY("dd"), + + /** + *

+ */ + HOUR("HH"), + + /** + *

+ */ + MINUTE("mm"), + + /** + *

+ */ + SECOND("ss"), + + /** + *

年月日

+ */ + FULL_DATE("yyyy-MM-dd"), + + /** + *

时分秒

+ */ + FULL_TIME("HH:mm:ss"), + + /** + *

年月日时分秒

+ */ + FULL_DATETIME("yyyy-MM-dd HH:mm:ss"), + + /** + *

月日时分

+ */ + SHORT_DATETIME("MM-dd HH:mm"), + ; + + private final String value; +} diff --git a/airpower-core/src/main/java/cn/hamm/airpower/model/query/QueryExport.java b/airpower-core/src/main/java/cn/hamm/airpower/model/query/QueryExport.java new file mode 100644 index 0000000000000000000000000000000000000000..ea8c82b0b9e7fc2b794fb7617bfe68e89f67c322 --- /dev/null +++ b/airpower-core/src/main/java/cn/hamm/airpower/model/query/QueryExport.java @@ -0,0 +1,18 @@ +package cn.hamm.airpower.model.query; + +import cn.hamm.airpower.root.RootModel; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + *

查询导出结果模型

+ * + * @author Hamm.cn + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class QueryExport extends RootModel { + @NotBlank(message = "文件Code不能为空") + private String fileCode; +} diff --git a/airpower-core/src/main/java/cn/hamm/airpower/root/RootEntity.java b/airpower-core/src/main/java/cn/hamm/airpower/root/RootEntity.java index 3e04e7cc87a9aafb9d30c4338be76a2cbbfd8e1b..d4c59b615b12745256cd23efae6907d670121841 100644 --- a/airpower-core/src/main/java/cn/hamm/airpower/root/RootEntity.java +++ b/airpower-core/src/main/java/cn/hamm/airpower/root/RootEntity.java @@ -45,6 +45,7 @@ public class RootEntity> extends RootModel @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(nullable = false, columnDefinition = "bigint UNSIGNED comment 'ID'") @Min(value = 0, message = "ID必须大于{value}") + @ExcelColumn(ExcelColumn.Type.NUMBER) @NotNull(groups = {WhenUpdate.class, WhenIdRequired.class}, message = "ID不能为空") private Long id; @@ -53,6 +54,7 @@ public class RootEntity> extends RootModel @Column(columnDefinition = "text comment '备注'") @Length(max = 1000, message = "备注最多允许{max}个字符") @Exclude(filters = {WhenPayLoad.class}) + @ExcelColumn private String remark; @Description("是否禁用") @@ -60,30 +62,35 @@ public class RootEntity> extends RootModel @Search(Search.Mode.EQUALS) @Column(columnDefinition = "tinyint UNSIGNED default 0 comment '是否禁用'") @Exclude(filters = {WhenPayLoad.class}) + @ExcelColumn(ExcelColumn.Type.BOOLEAN) private Boolean isDisabled; @Description("创建时间") @ReadOnly @Column(columnDefinition = "bigint UNSIGNED default 0 comment '创建时间'") @Exclude(filters = {WhenPayLoad.class}) + @ExcelColumn(ExcelColumn.Type.DATETIME) private Long createTime; @Description("创建人ID") @ReadOnly @Column(columnDefinition = "bigint UNSIGNED default 0 comment '创建人ID'") @Exclude(filters = {WhenPayLoad.class}) + @ExcelColumn(ExcelColumn.Type.NUMBER) private Long createUserId; @Description("修改人ID") @ReadOnly @Column(columnDefinition = "bigint UNSIGNED default 0 comment '修改人ID'") @Exclude(filters = {WhenPayLoad.class}) + @ExcelColumn(ExcelColumn.Type.NUMBER) private Long updateUserId; @Description("修改时间") @ReadOnly @Column(columnDefinition = "bigint UNSIGNED default 0 comment '修改时间'") @Exclude(filters = {WhenPayLoad.class}) + @ExcelColumn(ExcelColumn.Type.DATETIME) private Long updateTime; @Transient diff --git a/airpower-core/src/main/java/cn/hamm/airpower/root/RootEntityController.java b/airpower-core/src/main/java/cn/hamm/airpower/root/RootEntityController.java index e987ad8c71b1ca71935a345b44314d9b08a72941..841c3ecdbed1f3fb626f5389f1b59d96fe588356 100644 --- a/airpower-core/src/main/java/cn/hamm/airpower/root/RootEntityController.java +++ b/airpower-core/src/main/java/cn/hamm/airpower/root/RootEntityController.java @@ -10,6 +10,7 @@ import cn.hamm.airpower.enums.ServiceError; import cn.hamm.airpower.exception.ServiceException; import cn.hamm.airpower.interfaces.IEntityAction; import cn.hamm.airpower.model.Json; +import cn.hamm.airpower.model.query.QueryExport; import cn.hamm.airpower.model.query.QueryPageRequest; import cn.hamm.airpower.model.query.QueryPageResponse; import cn.hamm.airpower.model.query.QueryRequest; @@ -42,6 +43,25 @@ public class RootEntityController< @Autowired protected S service; + /** + *

创建导出任务

+ */ + @Description("创建导出任务") + @RequestMapping("export") + public Json export(@RequestBody QueryRequest queryRequest) { + return Json.data(service.createExportTask(queryRequest), "导出任务创建成功"); + } + + /** + *

查询异步导出结果

+ */ + @Description("查询异步导出结果") + @RequestMapping("queryExport") + @Permission(authorize = false) + public Json queryExport(@RequestBody @Validated QueryExport queryExport) { + return Json.data(service.queryExport(queryExport), "请下载导出的文件"); + } + /** *

添加一条新数据接口

* diff --git a/airpower-core/src/main/java/cn/hamm/airpower/root/RootService.java b/airpower-core/src/main/java/cn/hamm/airpower/root/RootService.java index 43ecc0e2f7a1bbe151043f456dda0f11b84b4388..dbbd417934dd9055fc8f70d8da72a7decc62e82e 100644 --- a/airpower-core/src/main/java/cn/hamm/airpower/root/RootService.java +++ b/airpower-core/src/main/java/cn/hamm/airpower/root/RootService.java @@ -1,17 +1,25 @@ package cn.hamm.airpower.root; +import cn.hamm.airpower.annotation.ExcelColumn; import cn.hamm.airpower.annotation.Search; import cn.hamm.airpower.config.Configs; import cn.hamm.airpower.config.Constant; import cn.hamm.airpower.config.MessageConstant; +import cn.hamm.airpower.enums.DateTimeFormatter; import cn.hamm.airpower.enums.ServiceError; import cn.hamm.airpower.exception.ServiceException; +import cn.hamm.airpower.interfaces.IDictionary; +import cn.hamm.airpower.model.Json; import cn.hamm.airpower.model.Page; import cn.hamm.airpower.model.Sort; +import cn.hamm.airpower.model.query.QueryExport; import cn.hamm.airpower.model.query.QueryPageRequest; import cn.hamm.airpower.model.query.QueryPageResponse; import cn.hamm.airpower.model.query.QueryRequest; +import cn.hamm.airpower.util.DateTimeUtil; +import cn.hamm.airpower.util.ReflectUtil; import cn.hamm.airpower.util.Utils; +import cn.hamm.airpower.validate.dictionary.Dictionary; import jakarta.persistence.Column; import jakarta.persistence.criteria.*; import lombok.extern.slf4j.Slf4j; @@ -29,8 +37,12 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.util.StringUtils; import java.beans.PropertyDescriptor; +import java.io.File; import java.lang.reflect.Field; import java.lang.reflect.ParameterizedType; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.*; import java.util.function.BiFunction; @@ -44,12 +56,217 @@ import java.util.function.BiFunction; @SuppressWarnings({"unchecked", "SpringJavaInjectionPointsAutowiringInspection"}) @Slf4j public class RootService, R extends RootRepository> { + /** *

数据源

*/ @Autowired protected R repository; + /** + *

导出文件前缀

+ */ + public static final String EXPORT_FILE_PREFIX = "export_file_"; + + /** + *

导出文件后缀

+ */ + public static final String EXPORT_FILE_CSV = ".csv"; + + /** + *

创建导出任务

+ * + * @param queryRequest 请求查询的参数 + * @return 导出任务ID + */ + public final String createExportTask(QueryRequest queryRequest) { + String fileCode = Utils.getRandomUtil().randomString().toLowerCase(); + final String fileCacheKey = EXPORT_FILE_PREFIX + fileCode; + Object object = Utils.getRedisUtil().get(fileCacheKey); + if (Objects.nonNull(object)) { + return createExportTask(queryRequest); + } + Utils.getRedisUtil().set(fileCacheKey, ""); + Utils.getTaskUtil().runAsync(() -> { + // 查数据 写文件 + List list = getList(queryRequest); + list = beforeExport(list); + String url = saveExportFile(list); + Utils.getRedisUtil().set(fileCacheKey, url); + }); + return fileCode; + } + + /** + *

保存导出的数据到文件

+ * + * @param exportList 导出的数据 + * @return 存储的文件地址 + * @apiNote 支持完全重写导出逻辑 + * + *
    + *
  • 默认导出为 CSV 表格,如需自定义导出方式或格式,可直接重写此方法
  • + *
  • 如仅需自定义导出存储位置,可重写 {@link #afterExport(String)}
  • + *
+ */ + protected String saveExportFile(List exportList) { + // 导出到csv并存储文件 + ReflectUtil reflectUtil = Utils.getReflectUtil(); + List fieldNameList = new ArrayList<>(); + List fieldList = new ArrayList<>(); + + List headerList = new ArrayList<>(); + Class entityClass = getEntityClass(); + for (Field field : reflectUtil.getFieldList(entityClass)) { + ExcelColumn excelColumn = reflectUtil.getAnnotation(ExcelColumn.class, field); + if (Objects.isNull(excelColumn)) { + continue; + } + fieldList.add(field); + fieldNameList.add(field.getName()); + String fieldName = reflectUtil.getDescription(field); + headerList.add(fieldName); + } + + List rowList = new ArrayList<>(); + // 添加表头 + rowList.add(String.join(Constant.COMMA, headerList)); + + String json = Json.toString(exportList); + List> mapList = Json.parse2MapList(json); + for (Map map : mapList) { + List columnList = new ArrayList<>(); + for (String fieldName : fieldNameList) { + Object value = map.get(fieldName); + value = prepareExcelColumn(fieldName, value, fieldList); + value = value.toString().replaceAll(Constant.COMMA, Constant.SPACE).replaceAll(Constant.LINE_BREAK, Constant.SPACE); + columnList.add(value.toString()); + } + rowList.add(String.join(Constant.COMMA, columnList)); + } + String content = String.join(Constant.LINE_BREAK, rowList); + return afterExport(content); + } + + /** + *

导出数据后置方法

+ * + * @param content 导出的CSV数据 + * @return 存储后的可访问路径 + * @apiNote 可存储至其他地方后返回可访问绝对路径 + */ + protected String afterExport(String content) { + // 路径分隔符 + final String separator = File.separator; + + // 准备导出的相对路径 + String exportFilePath = "export_"; + final String absolutePath = Configs.getServiceConfig().getExportFilePath() + separator; + ServiceError.SERVICE_ERROR.when(!StringUtils.hasText(absolutePath), "导出失败,未配置导出文件目录"); + + try { + DateTimeUtil dateTimeUtil = Utils.getDateTimeUtil(); + long milliSecond = System.currentTimeMillis(); + + // 追加今日文件夹 定时任务将按存储文件夹进行删除过时文件 + String todayDir = dateTimeUtil.format(milliSecond, + DateTimeFormatter.FULL_DATE.getValue() + .replaceAll(Constant.LINE, Constant.EMPTY_STRING) + ); + exportFilePath += todayDir + separator; + + if (!Files.exists(Paths.get(absolutePath + exportFilePath))) { + Files.createDirectory(Paths.get(absolutePath + exportFilePath)); + } + + // 存储的文件名 + final String fileName = todayDir + Constant.UNDERLINE + dateTimeUtil.format(milliSecond, + DateTimeFormatter.FULL_TIME.getValue() + .replaceAll(Constant.COLON, Constant.EMPTY_STRING) + ) + Constant.UNDERLINE + Utils.getRandomUtil().randomString() + EXPORT_FILE_CSV; + + // 拼接最终存储路径 + exportFilePath += fileName; + Path path = Paths.get(absolutePath + exportFilePath); + Files.writeString(path, content); + return exportFilePath; + } catch (Exception exception) { + log.error(exception.getMessage(), exception); + throw new ServiceException(exception); + } + } + + /** + *

准备导出列

+ * + * @param fieldName 字段名 + * @param value 当前值 + * @param fieldList 字段列表 + * @return 处理后的值 + */ + private @NotNull Object prepareExcelColumn(String fieldName, Object value, List fieldList) { + if (Objects.isNull(value)) { + value = Constant.LINE; + } + if (!StringUtils.hasText(value.toString())) { + value = Constant.LINE; + } + ReflectUtil reflectUtil = Utils.getReflectUtil(); + try { + Field field = fieldList.stream().filter(item -> item.getName().equals(fieldName)).findFirst().orElse(null); + if (Objects.isNull(field)) { + return value; + } + ExcelColumn excelColumn = reflectUtil.getAnnotation(ExcelColumn.class, field); + if (Objects.isNull(excelColumn)) { + return value; + } + + return switch (excelColumn.value()) { + case DATETIME -> Constant.TAB + Utils.getDateTimeUtil().format(Long.parseLong(value.toString())); + case TEXT -> Constant.TAB + value; + case BOOLEAN -> (boolean) value ? Constant.YES : Constant.NO; + case DICTIONARY -> { + Dictionary dictionary = reflectUtil.getAnnotation(Dictionary.class, field); + if (Objects.isNull(dictionary)) { + yield value; + } else { + IDictionary dict = Utils.getDictionaryUtil().getDictionary(dictionary.value(), Integer.parseInt(value.toString())); + yield dict.getLabel(); + } + } + default -> value; + }; + } catch (Exception exception) { + log.error(exception.getMessage(), exception); + return value; + } + } + + /** + *

导出前置方法

+ * + * @param exportList 导出的数据列表 + * @return 处理后的数据列表 + */ + protected List beforeExport(@NotNull List exportList) { + return exportList; + } + + /** + *

查询导出结果

+ * + * @param queryExportModel 查询导出模型 + * @return 导出文件地址 + */ + protected final String queryExport(@NotNull QueryExport queryExportModel) { + final String fileCacheKey = EXPORT_FILE_PREFIX + queryExportModel.getFileCode(); + Object object = Utils.getRedisUtil().get(fileCacheKey); + ServiceError.DATA_NOT_FOUND.whenNull(object, "错误的FileCode"); + ServiceError.DATA_NOT_FOUND.whenEmpty(object, "文件暂未准备完毕"); + return object.toString(); + } + /** *

🟢添加前置方法

* @@ -780,7 +997,7 @@ public class RootService, R extends RootRepository> { * @return 搜索条件 */ @SuppressWarnings("AlibabaSwitchStatement") - private @NotNull List getPredicateList( + private @NotNull List getPredicateList( @NotNull From root, @NotNull CriteriaBuilder builder, @NotNull Object search, boolean isEqual ) { List predicateList = new ArrayList<>(); diff --git a/airpower-core/src/main/java/cn/hamm/airpower/util/DateTimeUtil.java b/airpower-core/src/main/java/cn/hamm/airpower/util/DateTimeUtil.java new file mode 100644 index 0000000000000000000000000000000000000000..d2290d04ac5f2d66f62d38ee09d9b09709a18858 --- /dev/null +++ b/airpower-core/src/main/java/cn/hamm/airpower/util/DateTimeUtil.java @@ -0,0 +1,68 @@ +package cn.hamm.airpower.util; + +import cn.hamm.airpower.enums.DateTimeFormatter; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +/** + *

时间日期格式化

+ * + * @author Hamm.cn + */ +@Component +public class DateTimeUtil { + /** + *

默认时区

+ */ + private static final String ASIA_CHONGQING = "Asia/Chongqing"; + + /** + *

格式化时间

+ * + * @param milliSecond 毫秒 + * @return 格式化后的时间 + */ + public final @NotNull String format(long milliSecond) { + return format(milliSecond, DateTimeFormatter.FULL_DATETIME.getValue()); + } + + /** + *

格式化时间

+ * + * @param milliSecond 毫秒 + * @param formatter 格式化模板 + * @return 格式化后的时间 + */ + public final @NotNull String format(long milliSecond, DateTimeFormatter formatter) { + return format(milliSecond, formatter.getValue()); + } + + /** + *

格式化时间

+ * + * @param milliSecond 毫秒 + * @param formatter 格式化模板 + * @return 格式化后的时间 + */ + public final @NotNull String format(long milliSecond, String formatter) { + return format(milliSecond, formatter, ASIA_CHONGQING); + } + + /** + *

格式化时间

+ * + * @param milliSecond 毫秒 + * @param formatter 格式化模板 + * @param zone 时区 + * @return 格式化后的时间 + */ + public final @NotNull String format(long milliSecond, String formatter, String zone) { + Instant instant = Instant.ofEpochMilli(milliSecond); + ZonedDateTime beijingTime = instant.atZone(ZoneId.of(zone)); + return beijingTime.format(java.time.format.DateTimeFormatter.ofPattern(formatter)); + } +} diff --git a/airpower-core/src/main/java/cn/hamm/airpower/util/ReflectUtil.java b/airpower-core/src/main/java/cn/hamm/airpower/util/ReflectUtil.java index 07caf02eee0cd7496a8701387369f19753b11455..450e1a873509eacebf1c22b3ea28512d8209b25c 100644 --- a/airpower-core/src/main/java/cn/hamm/airpower/util/ReflectUtil.java +++ b/airpower-core/src/main/java/cn/hamm/airpower/util/ReflectUtil.java @@ -353,4 +353,22 @@ public class ReflectUtil { } return getAnnotation(annotationClass, superMethod, superClass); } + + /** + *

递归获取字段

+ * + * @param fieldName 字段名 + * @param clazz 当前类 + * @return 字段 + */ + public final @Nullable Field getField(String fieldName, Class clazz) { + if (Objects.isNull(clazz) || Object.class.equals(clazz)) { + return null; + } + try { + return clazz.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + return getField(fieldName, clazz.getSuperclass()); + } + } } diff --git a/airpower-core/src/main/java/cn/hamm/airpower/util/Utils.java b/airpower-core/src/main/java/cn/hamm/airpower/util/Utils.java index f767c6e33bd3e756e96169822f07286d9adcbe71..c0bc63aa84b6a29be40dd0b144818439d84d6945 100644 --- a/airpower-core/src/main/java/cn/hamm/airpower/util/Utils.java +++ b/airpower-core/src/main/java/cn/hamm/airpower/util/Utils.java @@ -168,6 +168,12 @@ public class Utils { @Getter private static TaskUtil taskUtil; + /** + *

日期时间工具

+ */ + @Getter + private static DateTimeUtil dateTimeUtil; + @Autowired Utils( RedisUtil redisUtil, @@ -194,7 +200,8 @@ public class Utils { StringUtil stringUtil, WebsocketUtil websocketUtil, AesUtil aesUtil, - TaskUtil taskUtil + TaskUtil taskUtil, + DateTimeUtil dateTimeUtil ) { Utils.redisUtil = redisUtil; Utils.emailUtil = emailUtil; @@ -221,6 +228,7 @@ public class Utils { Utils.websocketUtil = websocketUtil; Utils.aesUtil = aesUtil; Utils.taskUtil = taskUtil; + Utils.dateTimeUtil = dateTimeUtil; } /** diff --git a/pom.xml b/pom.xml index 52066a2d4c4b4f0aa2a45795a6578be0406a956c..178c8f6ca01dfe9bbf983e933c05dbd7cb410635 100644 --- a/pom.xml +++ b/pom.xml @@ -4,11 +4,11 @@ 4.0.0 cn.hamm airpower - 2.1.0 + 2.1.1 airpower AirPower is a fast backend development tool based on SpringBoot3 and JPA. - 2.1.0 + 2.1.1 UTF-8 UTF-8 UTF-8