keys, boolean fragment) {
+ UriComponentsBuilder template = UriComponentsBuilder.newInstance();
+ UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(base);
+ URI redirectUri;
+ try {
+ // assume it's encoded to start with (if it came in over the wire)
+ redirectUri = builder.build(true).toUri();
+ } catch (Exception e) {
+ // ... but allow client registrations to contain hard-coded non-encoded values
+ redirectUri = builder.build().toUri();
+ builder = UriComponentsBuilder.fromUri(redirectUri);
+ }
+ template.scheme(redirectUri.getScheme()).port(redirectUri.getPort()).host(redirectUri.getHost())
+ .userInfo(redirectUri.getUserInfo()).path(redirectUri.getPath());
+
+ if (fragment) {
+ StringBuilder values = new StringBuilder();
+ if (redirectUri.getFragment() != null) {
+ String append = redirectUri.getFragment();
+ values.append(append);
+ }
+ for (String key : query.keySet()) {
+ if (values.length() > 0) {
+ values.append("&");
+ }
+ String name = key;
+ if (keys != null && keys.containsKey(key)) {
+ name = keys.get(key);
+ }
+ values.append(name).append("={").append(key).append("}");
+ }
+ if (values.length() > 0) {
+ template.fragment(values.toString());
+ }
+ UriComponents encoded = template.build().expand(query).encode();
+ builder.fragment(encoded.getFragment());
+ } else {
+ for (String key : query.keySet()) {
+ String name = key;
+ if (keys != null && keys.containsKey(key)) {
+ name = keys.get(key);
+ }
+ template.queryParam(name, "{" + key + "}");
+ }
+ template.fragment(redirectUri.getFragment());
+ UriComponents encoded = template.build().expand(query).encode();
+ builder.query(encoded.getQuery());
+ }
+ return builder.build().toUriString();
+ }
+
+ public static String[] obtainBasicAuthorization(HttpServletRequest request) {
+ String clientId;
+ String clientSecret;
+ // 先从 Header 中获取
+ String authorization = request.getHeader("Authorization");
+ authorization = StrUtil.subAfter(authorization, "Basic ", true);
+ if (StringUtils.hasText(authorization)) {
+ authorization = Base64.decodeStr(authorization);
+ clientId = StrUtil.subBefore(authorization, ":", false);
+ clientSecret = StrUtil.subAfter(authorization, ":", false);
+ // 再从 Param 中获取
+ } else {
+ clientId = request.getParameter("client_id");
+ clientSecret = request.getParameter("client_secret");
+ }
+
+ // 如果两者非空,则返回
+ if (StrUtil.isNotEmpty(clientId) && StrUtil.isNotEmpty(clientSecret)) {
+ return new String[]{clientId, clientSecret};
+ }
+ return null;
+ }
+
+
+}
diff --git a/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/io/FileUtils.java b/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/io/FileUtils.java
new file mode 100644
index 0000000..0759bb7
--- /dev/null
+++ b/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/io/FileUtils.java
@@ -0,0 +1,84 @@
+package cn.hangtag.framework.common.util.io;
+
+import cn.hutool.core.io.FileTypeUtil;
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.io.file.FileNameUtil;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.digest.DigestUtil;
+import lombok.SneakyThrows;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+
+/**
+ * 文件工具类
+ *
+ * @author 芋道源码
+ */
+public class FileUtils {
+
+ /**
+ * 创建临时文件
+ * 该文件会在 JVM 退出时,进行删除
+ *
+ * @param data 文件内容
+ * @return 文件
+ */
+ @SneakyThrows
+ public static File createTempFile(String data) {
+ File file = createTempFile();
+ // 写入内容
+ FileUtil.writeUtf8String(data, file);
+ return file;
+ }
+
+ /**
+ * 创建临时文件
+ * 该文件会在 JVM 退出时,进行删除
+ *
+ * @param data 文件内容
+ * @return 文件
+ */
+ @SneakyThrows
+ public static File createTempFile(byte[] data) {
+ File file = createTempFile();
+ // 写入内容
+ FileUtil.writeBytes(data, file);
+ return file;
+ }
+
+ /**
+ * 创建临时文件,无内容
+ * 该文件会在 JVM 退出时,进行删除
+ *
+ * @return 文件
+ */
+ @SneakyThrows
+ public static File createTempFile() {
+ // 创建文件,通过 UUID 保证唯一
+ File file = File.createTempFile(IdUtil.simpleUUID(), null);
+ // 标记 JVM 退出时,自动删除
+ file.deleteOnExit();
+ return file;
+ }
+
+ /**
+ * 生成文件路径
+ *
+ * @param content 文件内容
+ * @param originalName 原始文件名
+ * @return path,唯一不可重复
+ */
+ public static String generatePath(byte[] content, String originalName) {
+ String sha256Hex = DigestUtil.sha256Hex(content);
+ // 情况一:如果存在 name,则优先使用 name 的后缀
+ if (StrUtil.isNotBlank(originalName)) {
+ String extName = FileNameUtil.extName(originalName);
+ return StrUtil.isBlank(extName) ? sha256Hex : sha256Hex + "." + extName;
+ }
+ // 情况二:基于 content 计算
+ return sha256Hex + '.' + FileTypeUtil.getType(new ByteArrayInputStream(content));
+ }
+
+}
diff --git a/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/io/IoUtils.java b/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/io/IoUtils.java
new file mode 100644
index 0000000..de9a45d
--- /dev/null
+++ b/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/io/IoUtils.java
@@ -0,0 +1,28 @@
+package cn.hangtag.framework.common.util.io;
+
+import cn.hutool.core.io.IORuntimeException;
+import cn.hutool.core.io.IoUtil;
+import cn.hutool.core.util.StrUtil;
+
+import java.io.InputStream;
+
+/**
+ * IO 工具类,用于 {@link cn.hutool.core.io.IoUtil} 缺失的方法
+ *
+ * @author 芋道源码
+ */
+public class IoUtils {
+
+ /**
+ * 从流中读取 UTF8 编码的内容
+ *
+ * @param in 输入流
+ * @param isClose 是否关闭
+ * @return 内容
+ * @throws IORuntimeException IO 异常
+ */
+ public static String readUtf8(InputStream in, boolean isClose) throws IORuntimeException {
+ return StrUtil.utf8Str(IoUtil.read(in, isClose));
+ }
+
+}
diff --git a/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/json/JsonUtils.java b/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/json/JsonUtils.java
new file mode 100644
index 0000000..693113d
--- /dev/null
+++ b/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/json/JsonUtils.java
@@ -0,0 +1,202 @@
+package cn.hangtag.framework.common.util.json;
+
+import cn.hutool.core.util.ArrayUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONUtil;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.IOException;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * JSON 工具类
+ *
+ * @author 芋道源码
+ */
+@Slf4j
+public class JsonUtils {
+
+ private static ObjectMapper objectMapper = new ObjectMapper();
+
+ static {
+ objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
+ objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+ objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); // 忽略 null 值
+ objectMapper.registerModules(new JavaTimeModule()); // 解决 LocalDateTime 的序列化
+ }
+
+ /**
+ * 初始化 objectMapper 属性
+ *
+ * 通过这样的方式,使用 Spring 创建的 ObjectMapper Bean
+ *
+ * @param objectMapper ObjectMapper 对象
+ */
+ public static void init(ObjectMapper objectMapper) {
+ JsonUtils.objectMapper = objectMapper;
+ }
+
+ @SneakyThrows
+ public static String toJsonString(Object object) {
+ return objectMapper.writeValueAsString(object);
+ }
+
+ @SneakyThrows
+ public static byte[] toJsonByte(Object object) {
+ return objectMapper.writeValueAsBytes(object);
+ }
+
+ @SneakyThrows
+ public static String toJsonPrettyString(Object object) {
+ return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(object);
+ }
+
+ public static T parseObject(String text, Class clazz) {
+ if (StrUtil.isEmpty(text)) {
+ return null;
+ }
+ try {
+ return objectMapper.readValue(text, clazz);
+ } catch (IOException e) {
+ log.error("json parse err,json:{}", text, e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static T parseObject(String text, String path, Class clazz) {
+ if (StrUtil.isEmpty(text)) {
+ return null;
+ }
+ try {
+ JsonNode treeNode = objectMapper.readTree(text);
+ JsonNode pathNode = treeNode.path(path);
+ return objectMapper.readValue(pathNode.toString(), clazz);
+ } catch (IOException e) {
+ log.error("json parse err,json:{}", text, e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static T parseObject(String text, Type type) {
+ if (StrUtil.isEmpty(text)) {
+ return null;
+ }
+ try {
+ return objectMapper.readValue(text, objectMapper.getTypeFactory().constructType(type));
+ } catch (IOException e) {
+ log.error("json parse err,json:{}", text, e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * 将字符串解析成指定类型的对象
+ * 使用 {@link #parseObject(String, Class)} 时,在@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) 的场景下,
+ * 如果 text 没有 class 属性,则会报错。此时,使用这个方法,可以解决。
+ *
+ * @param text 字符串
+ * @param clazz 类型
+ * @return 对象
+ */
+ public static T parseObject2(String text, Class clazz) {
+ if (StrUtil.isEmpty(text)) {
+ return null;
+ }
+ return JSONUtil.toBean(text, clazz);
+ }
+
+ public static T parseObject(byte[] bytes, Class clazz) {
+ if (ArrayUtil.isEmpty(bytes)) {
+ return null;
+ }
+ try {
+ return objectMapper.readValue(bytes, clazz);
+ } catch (IOException e) {
+ log.error("json parse err,json:{}", bytes, e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static T parseObject(String text, TypeReference typeReference) {
+ try {
+ return objectMapper.readValue(text, typeReference);
+ } catch (IOException e) {
+ log.error("json parse err,json:{}", text, e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * 解析 JSON 字符串成指定类型的对象,如果解析失败,则返回 null
+ *
+ * @param text 字符串
+ * @param typeReference 类型引用
+ * @return 指定类型的对象
+ */
+ public static T parseObjectQuietly(String text, TypeReference typeReference) {
+ try {
+ return objectMapper.readValue(text, typeReference);
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ public static List parseArray(String text, Class clazz) {
+ if (StrUtil.isEmpty(text)) {
+ return new ArrayList<>();
+ }
+ try {
+ return objectMapper.readValue(text, objectMapper.getTypeFactory().constructCollectionType(List.class, clazz));
+ } catch (IOException e) {
+ log.error("json parse err,json:{}", text, e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static List parseArray(String text, String path, Class clazz) {
+ if (StrUtil.isEmpty(text)) {
+ return null;
+ }
+ try {
+ JsonNode treeNode = objectMapper.readTree(text);
+ JsonNode pathNode = treeNode.path(path);
+ return objectMapper.readValue(pathNode.toString(), objectMapper.getTypeFactory().constructCollectionType(List.class, clazz));
+ } catch (IOException e) {
+ log.error("json parse err,json:{}", text, e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static JsonNode parseTree(String text) {
+ try {
+ return objectMapper.readTree(text);
+ } catch (IOException e) {
+ log.error("json parse err,json:{}", text, e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static JsonNode parseTree(byte[] text) {
+ try {
+ return objectMapper.readTree(text);
+ } catch (IOException e) {
+ log.error("json parse err,json:{}", text, e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static boolean isJson(String text) {
+ return JSONUtil.isTypeJSON(text);
+ }
+
+}
diff --git a/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/monitor/TracerUtils.java b/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/monitor/TracerUtils.java
new file mode 100644
index 0000000..b8eda9f
--- /dev/null
+++ b/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/monitor/TracerUtils.java
@@ -0,0 +1,30 @@
+package cn.hangtag.framework.common.util.monitor;
+
+import org.apache.skywalking.apm.toolkit.trace.TraceContext;
+
+/**
+ * 链路追踪工具类
+ *
+ * 考虑到每个 starter 都需要用到该工具类,所以放到 common 模块下的 util 包下
+ *
+ * @author 芋道源码
+ */
+public class TracerUtils {
+
+ /**
+ * 私有化构造方法
+ */
+ private TracerUtils() {
+ }
+
+ /**
+ * 获得链路追踪编号,直接返回 SkyWalking 的 TraceId。
+ * 如果不存在的话为空字符串!!!
+ *
+ * @return 链路追踪编号
+ */
+ public static String getTraceId() {
+ return TraceContext.traceId();
+ }
+
+}
diff --git a/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/number/MoneyUtils.java b/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/number/MoneyUtils.java
new file mode 100644
index 0000000..e728ecc
--- /dev/null
+++ b/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/number/MoneyUtils.java
@@ -0,0 +1,131 @@
+package cn.hangtag.framework.common.util.number;
+
+import cn.hutool.core.math.Money;
+import cn.hutool.core.util.NumberUtil;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+/**
+ * 金额工具类
+ *
+ * @author 芋道源码
+ */
+public class MoneyUtils {
+
+ /**
+ * 金额的小数位数
+ */
+ private static final int PRICE_SCALE = 2;
+
+ /**
+ * 百分比对应的 BigDecimal 对象
+ */
+ public static final BigDecimal PERCENT_100 = BigDecimal.valueOf(100);
+
+ /**
+ * 计算百分比金额,四舍五入
+ *
+ * @param price 金额
+ * @param rate 百分比,例如说 56.77% 则传入 56.77
+ * @return 百分比金额
+ */
+ public static Integer calculateRatePrice(Integer price, Double rate) {
+ return calculateRatePrice(price, rate, 0, RoundingMode.HALF_UP).intValue();
+ }
+
+ /**
+ * 计算百分比金额,向下传入
+ *
+ * @param price 金额
+ * @param rate 百分比,例如说 56.77% 则传入 56.77
+ * @return 百分比金额
+ */
+ public static Integer calculateRatePriceFloor(Integer price, Double rate) {
+ return calculateRatePrice(price, rate, 0, RoundingMode.FLOOR).intValue();
+ }
+
+ /**
+ * 计算百分比金额
+ *
+ * @param price 金额(单位分)
+ * @param count 数量
+ * @param percent 折扣(单位分),列如 60.2%,则传入 6020
+ * @return 商品总价
+ */
+ public static Integer calculator(Integer price, Integer count, Integer percent) {
+ price = price * count;
+ if (percent == null) {
+ return price;
+ }
+ return MoneyUtils.calculateRatePriceFloor(price, (double) (percent / 100));
+ }
+
+ /**
+ * 计算百分比金额
+ *
+ * @param price 金额
+ * @param rate 百分比,例如说 56.77% 则传入 56.77
+ * @param scale 保留小数位数
+ * @param roundingMode 舍入模式
+ */
+ public static BigDecimal calculateRatePrice(Number price, Number rate, int scale, RoundingMode roundingMode) {
+ return NumberUtil.toBigDecimal(price).multiply(NumberUtil.toBigDecimal(rate)) // 乘以
+ .divide(BigDecimal.valueOf(100), scale, roundingMode); // 除以 100
+ }
+
+ /**
+ * 分转元
+ *
+ * @param fen 分
+ * @return 元
+ */
+ public static BigDecimal fenToYuan(int fen) {
+ return new Money(0, fen).getAmount();
+ }
+
+ /**
+ * 分转元(字符串)
+ *
+ * 例如说 fen 为 1 时,则结果为 0.01
+ *
+ * @param fen 分
+ * @return 元
+ */
+ public static String fenToYuanStr(int fen) {
+ return new Money(0, fen).toString();
+ }
+
+ /**
+ * 金额相乘,默认进行四舍五入
+ *
+ * 位数:{@link #PRICE_SCALE}
+ *
+ * @param price 金额
+ * @param count 数量
+ * @return 金额相乘结果
+ */
+ public static BigDecimal priceMultiply(BigDecimal price, BigDecimal count) {
+ if (price == null || count == null) {
+ return null;
+ }
+ return price.multiply(count).setScale(PRICE_SCALE, RoundingMode.HALF_UP);
+ }
+
+ /**
+ * 金额相乘(百分比),默认进行四舍五入
+ *
+ * 位数:{@link #PRICE_SCALE}
+ *
+ * @param price 金额
+ * @param percent 百分比
+ * @return 金额相乘结果
+ */
+ public static BigDecimal priceMultiplyPercent(BigDecimal price, BigDecimal percent) {
+ if (price == null || percent == null) {
+ return null;
+ }
+ return price.multiply(percent).divide(PERCENT_100, PRICE_SCALE, RoundingMode.HALF_UP);
+ }
+
+}
diff --git a/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/number/NumberUtils.java b/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/number/NumberUtils.java
new file mode 100644
index 0000000..27a65e8
--- /dev/null
+++ b/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/number/NumberUtils.java
@@ -0,0 +1,64 @@
+package cn.hangtag.framework.common.util.number;
+
+import cn.hutool.core.util.NumberUtil;
+import cn.hutool.core.util.StrUtil;
+
+import java.math.BigDecimal;
+
+/**
+ * 数字的工具类,补全 {@link cn.hutool.core.util.NumberUtil} 的功能
+ *
+ * @author 芋道源码
+ */
+public class NumberUtils {
+
+ public static Long parseLong(String str) {
+ return StrUtil.isNotEmpty(str) ? Long.valueOf(str) : null;
+ }
+
+ public static Integer parseInt(String str) {
+ return StrUtil.isNotEmpty(str) ? Integer.valueOf(str) : null;
+ }
+
+ /**
+ * 通过经纬度获取地球上两点之间的距离
+ *
+ * 参考 <DistanceUtil> 实现,目前它已经被 hutool 删除
+ *
+ * @param lat1 经度1
+ * @param lng1 纬度1
+ * @param lat2 经度2
+ * @param lng2 纬度2
+ * @return 距离,单位:千米
+ */
+ public static double getDistance(double lat1, double lng1, double lat2, double lng2) {
+ double radLat1 = lat1 * Math.PI / 180.0;
+ double radLat2 = lat2 * Math.PI / 180.0;
+ double a = radLat1 - radLat2;
+ double b = lng1 * Math.PI / 180.0 - lng2 * Math.PI / 180.0;
+ double distance = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2)
+ + Math.cos(radLat1) * Math.cos(radLat2)
+ * Math.pow(Math.sin(b / 2), 2)));
+ distance = distance * 6378.137;
+ distance = Math.round(distance * 10000d) / 10000d;
+ return distance;
+ }
+
+ /**
+ * 提供精确的乘法运算
+ *
+ * 和 hutool {@link NumberUtil#mul(BigDecimal...)} 的差别是,如果存在 null,则返回 null
+ *
+ * @param values 多个被乘值
+ * @return 积
+ */
+ public static BigDecimal mul(BigDecimal... values) {
+ for (BigDecimal value : values) {
+ if (value == null) {
+ return null;
+ }
+ }
+ return NumberUtil.mul(values);
+ }
+
+}
diff --git a/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/object/BeanUtils.java b/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/object/BeanUtils.java
new file mode 100644
index 0000000..da3186a
--- /dev/null
+++ b/hangtag-framework/hangtag-common/src/main/java/cn/hangtag/framework/common/util/object/BeanUtils.java
@@ -0,0 +1,62 @@
+package cn.hangtag.framework.common.util.object;
+
+import cn.hutool.core.bean.BeanUtil;
+import cn.hangtag.framework.common.pojo.PageResult;
+import cn.hangtag.framework.common.util.collection.CollectionUtils;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * Bean 工具类
+ *
+ * 1. 默认使用 {@link cn.hutool.core.bean.BeanUtil} 作为实现类,虽然不同 bean 工具的性能有差别,但是对绝大多数同学的项目,不用在意这点性能
+ * 2. 针对复杂的对象转换,可以搜参考 AuthConvert 实现,通过 mapstruct + default 配合实现
+ *
+ * @author 芋道源码
+ */
+public class BeanUtils {
+
+ public static T toBean(Object source, Class targetClass) {
+ return BeanUtil.toBean(source, targetClass);
+ }
+
+ public static T toBean(Object source, Class targetClass, Consumer peek) {
+ T target = toBean(source, targetClass);
+ if (target != null) {
+ peek.accept(target);
+ }
+ return target;
+ }
+
+ public static List toBean(List source, Class targetType) {
+ if (source == null) {
+ return null;
+ }
+ return CollectionUtils.convertList(source, s -> toBean(s, targetType));
+ }
+
+ public static List toBean(List source, Class