* !44 comment
* !39 添加下行日志打印
* !36 扩展计价领域模型
* !35 webui 初步成型
* !34 webui 初步成型
This commit is contained in:
三丙
2025-09-09 08:23:59 +00:00
parent 921045af8f
commit 58580ca11e
372 changed files with 37900 additions and 1206 deletions

View File

@@ -43,7 +43,6 @@
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
@@ -53,7 +52,6 @@
<groupId>cn.hutool</groupId>
<artifactId>hutool-core</artifactId>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
@@ -78,6 +76,10 @@
<groupId>io.netty</groupId>
<artifactId>netty-buffer</artifactId>
</dependency>
<dependency>
<groupId>org.owasp.antisamy</groupId>
<artifactId>antisamy</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,94 @@
/**
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
* 微信mohan_88888
* 抖音:程序员三丙
* 付费课程知识星球https://t.zsxq.com/aKtXo
*/
package sanbing.jcpp.infrastructure.util;
import java.util.*;
import java.util.stream.Collectors;
public class CollectionsUtil {
/**
* 判断集合是否为空null 或者 size 为 0
*/
public static boolean isEmpty(Collection<?> collection) {
return collection == null || collection.isEmpty();
}
/**
* 判断集合是否不为空。
*/
public static boolean isNotEmpty(Collection<?> collection) {
return !isEmpty(collection);
}
/**
* 返回存在于集合 B但不在集合 A中的元素的新集合。
*/
public static <T> Set<T> diffSets(Set<T> a, Set<T> b) {
return b.stream().filter(p -> !a.contains(p)).collect(Collectors.toSet());
}
/**
* 返回存在于列表 B但不在列表 A中的元素的新列表。
*/
public static <T> List<T> diffLists(List<T> a, List<T> b) {
return b.stream().filter(p -> !a.contains(p)).collect(Collectors.toList());
}
/**
* 判断集合中是否包含指定元素。
*/
public static <T> boolean contains(Collection<T> collection, T element) {
return isNotEmpty(collection) && collection.contains(element);
}
/**
* 统计数组中非空元素的数量。
*/
public static <T> int countNonNull(T[] array) {
int count = 0;
for (T t : array) {
if (t != null) count++;
}
return count;
}
/**
* 创建一个 Map传入键值对参数。
* 如果参数数量不是偶数,则抛出异常。
*/
@SuppressWarnings("unchecked")
public static <T> Map<T, T> mapOf(T... kvs) {
if (kvs.length % 2 != 0) {
throw new IllegalArgumentException("参数数量无效");
}
Map<T, T> map = new HashMap<>();
for (int i = 0; i < kvs.length; i += 2) {
T key = kvs[i];
T value = kvs[i + 1];
map.put(key, value);
}
return map;
}
/**
* 判断集合是否为空或者包含指定元素。
*/
public static <V> boolean emptyOrContains(Collection<V> collection, V element) {
return isEmpty(collection) || collection.contains(element);
}
/**
* 合并两个集合并返回一个新的 HashSet。
*/
public static <V> HashSet<V> concat(Set<V> set1, Set<V> set2) {
HashSet<V> result = new HashSet<>();
result.addAll(set1);
result.addAll(set2);
return result;
}
}

View File

@@ -13,7 +13,7 @@ import java.nio.charset.StandardCharsets;
import java.util.UUID;
/**
* @author baigod
* @author 九筒
*/
public class JCPPHashUtil {
public static HashFunction forName(String name) {

View File

@@ -15,7 +15,7 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author baigod
* @author 九筒
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)

View File

@@ -20,7 +20,7 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author baigod
* @author 九筒
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)

View File

@@ -0,0 +1,54 @@
/**
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
* 微信mohan_88888
* 抖音:程序员三丙
* 付费课程知识星球https://t.zsxq.com/aKtXo
*/
package sanbing.jcpp.infrastructure.util.async;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.concurrent.Callable;
public abstract class AbstractListeningExecutor implements ListeningExecutor {
private ListeningExecutorService service;
@PostConstruct
public void init() {
this.service = MoreExecutors.listeningDecorator(JCPPExecutors.newWorkStealingPool(getThreadPollSize(), getClass()));
}
@PreDestroy
public void destroy() {
if (this.service != null) {
this.service.shutdown();
}
}
@Override
public <T> ListenableFuture<T> executeAsync(Callable<T> task) {
return service.submit(task);
}
public ListenableFuture<?> executeAsync(Runnable task) {
return service.submit(task);
}
@Override
public void execute(Runnable command) {
service.execute(command);
}
public ListeningExecutorService executor() {
return service;
}
protected abstract int getThreadPollSize();
}

View File

@@ -9,6 +9,7 @@ package sanbing.jcpp.infrastructure.util.async;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicLong;
@SuppressWarnings("ALL")
public class JCPPVirtualThreadFactory implements ThreadFactory {
private final String namePrefix;
private final AtomicLong threadNumber = new AtomicLong(1);

View File

@@ -0,0 +1,33 @@
/**
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
* 微信mohan_88888
* 抖音:程序员三丙
* 付费课程知识星球https://t.zsxq.com/aKtXo
*/
package sanbing.jcpp.infrastructure.util.async;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.concurrent.Callable;
import java.util.concurrent.Executor;
public interface ListeningExecutor extends Executor {
<T> ListenableFuture<T> executeAsync(Callable<T> task);
default ListenableFuture<?> executeAsync(Runnable task) {
return executeAsync(() -> {
task.run();
return null;
});
}
default <T> ListenableFuture<T> submit(Callable<T> task) {
return executeAsync(task);
}
default ListenableFuture<?> submit(Runnable task) {
return executeAsync(task);
}
}

View File

@@ -190,7 +190,7 @@ public class BCDUtil {
*/
public static LocalDateTime bcdToDate(byte[] bcdBytes) {
if (bcdBytes == null || bcdBytes.length != BCD_DATE_LENGTH) {
throw new IllegalArgumentException("BCD date bytes must be 8 bytes long");
throw new IllegalArgumentException("BCD日期字节必须为8字节长度");
}
// 检查是否全为0

View File

@@ -15,7 +15,7 @@ import java.nio.charset.StandardCharsets;
import java.util.UUID;
/**
* @author baigod
* @author 九筒
*/
public class ByteUtil {
@@ -83,11 +83,11 @@ public class ByteUtil {
/**
* 计算字节数组的累加和如果累加结果超过1字节则只取低8位
*
* <p>
* 示例:
* byte[] data = {0x01, 0x02, 0x03};
* byte sum = calculateSum(data); // sum = 0x06
*
* <p>
* byte[] data2 = {(byte)0xFF, (byte)0xFF};
* byte sum2 = calculateSum(data2); // sum2 = (byte)0xFE (254 + 255 = 509, 取低8位为254)
*
@@ -110,7 +110,7 @@ public class ByteUtil {
/**
* 验证数据的累加和是否与期望值相等
*
* <p>
* 示例:
* byte[] data = {0x01, 0x02, 0x03};
* boolean valid = verifySum(data, (byte)0x06); // valid = true

View File

@@ -27,7 +27,7 @@ import static sanbing.jcpp.infrastructure.util.JCPPHashUtil.forName;
import static sanbing.jcpp.infrastructure.util.JCPPHashUtil.hash;
/**
* @author baigod
* @author 九筒
*/
@Component
@Slf4j

View File

@@ -17,7 +17,7 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* @author baigod
* @author 九筒
*/
@Configuration
public class ThreadPoolConfiguration {

View File

@@ -7,7 +7,7 @@
package sanbing.jcpp.infrastructure.util.exception;
/**
* @author baigod
* @author 九筒
*/
public class DownlinkException extends RuntimeException {

View File

@@ -24,7 +24,7 @@ import java.util.Date;
/**
* 类型转换
*
* @author baigod
* @author 九筒
*/
public class DataTypeModule extends SimpleModule {
public static final DataTypeModule INSTANCE = new DataTypeModule();

View File

@@ -19,7 +19,7 @@ import java.util.Date;
/**
* 时间反序列化
*
* @author baigod
* @author 九筒
*/
public class DateDeserializer extends JsonDeserializer<Date> {
public static final DateDeserializer INSTANCE = new DateDeserializer();

View File

@@ -17,7 +17,7 @@ import java.util.Date;
/**
* 时间序列化
*
* @author baigod
* @author 九筒
*/
public class DateSerializer extends StdSerializer<Date> {
public static final DateSerializer INSTANCE = new DateSerializer();

View File

@@ -18,7 +18,7 @@ import java.time.format.DateTimeFormatter;
/**
* Instant 反序列化
*
* @author baigod
* @author 九筒
*/
public class InstantDeserializer extends com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer<Instant> {
public static final InstantDeserializer INSTANCE = new InstantDeserializer();

View File

@@ -11,7 +11,7 @@ import java.time.format.DateTimeFormatter;
/**
* Instant 序列化
*
* @author baigod
* @author 九筒
*/
public class InstantSerializer extends com.fasterxml.jackson.datatype.jsr310.ser.InstantSerializer {
public static final InstantSerializer INSTANCE = new InstantSerializer();

View File

@@ -18,11 +18,13 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.util.Arrays;
import java.util.TimeZone;
/**
* @author baigod
* @author 九筒
*/
public class JacksonUtil {
@@ -51,8 +53,8 @@ public class JacksonUtil {
try {
return fromValue != null ? OBJECT_MAPPER.convertValue(fromValue, toValueType) : null;
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("The given object value: "
+ fromValue + " cannot be converted to " + toValueType, e);
throw new IllegalArgumentException("给定的对象值: "
+ fromValue + " 无法转换为 " + toValueType, e);
}
}
@@ -60,8 +62,8 @@ public class JacksonUtil {
try {
return fromValue != null ? OBJECT_MAPPER.convertValue(fromValue, toValueTypeRef) : null;
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("The given object value: "
+ fromValue + " cannot be converted to " + toValueTypeRef, e);
throw new IllegalArgumentException("给定的对象值: "
+ fromValue + " 无法转换为 " + toValueTypeRef, e);
}
}
@@ -69,8 +71,8 @@ public class JacksonUtil {
try {
return string != null ? OBJECT_MAPPER.readValue(string, clazz) : null;
} catch (IOException e) {
throw new IllegalArgumentException("The given string value: "
+ string + " cannot be transformed to Json object", e);
throw new IllegalArgumentException("给定的字符串值: "
+ string + " 无法转换为Json对象", e);
}
}
@@ -78,8 +80,8 @@ public class JacksonUtil {
try {
return string != null ? OBJECT_MAPPER.readValue(string, valueTypeRef) : null;
} catch (IOException e) {
throw new IllegalArgumentException("The given string value: "
+ string + " cannot be transformed to Json object", e);
throw new IllegalArgumentException("给定的字符串值: "
+ string + " 无法转换为Json对象", e);
}
}
@@ -87,8 +89,15 @@ public class JacksonUtil {
try {
return bytes != null ? OBJECT_MAPPER.readValue(bytes, clazz) : null;
} catch (IOException e) {
throw new IllegalArgumentException("The given string value: "
+ Arrays.toString(bytes) + " cannot be transformed to Json object", e);
throw new IllegalArgumentException("给定的字节数组: "
+ Arrays.toString(bytes) + " 无法转换为Json对象", e);
}
}
public static <T> T fromReader(Reader reader, Class<T> clazz) {
try {
return reader != null ? OBJECT_MAPPER.readValue(reader, clazz) : null;
} catch (IOException e) {
throw new IllegalArgumentException("给定的Reader无法转换为Json对象", e);
}
}
@@ -96,8 +105,8 @@ public class JacksonUtil {
try {
return OBJECT_MAPPER.readTree(bytes);
} catch (IOException e) {
throw new IllegalArgumentException("The given byte[] value: "
+ Arrays.toString(bytes) + " cannot be transformed to Json object", e);
throw new IllegalArgumentException("给定的字节数组: "
+ Arrays.toString(bytes) + " 无法转换为Json对象", e);
}
}
@@ -105,8 +114,7 @@ public class JacksonUtil {
try {
return value != null ? OBJECT_MAPPER.writeValueAsString(value) : null;
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("The given Json object value: "
+ value + " cannot be transformed to a String", e);
throw new IllegalArgumentException("给定的对象值无法转换为字符串: " + value, e);
}
}
@@ -118,26 +126,67 @@ public class JacksonUtil {
}
}
public static JsonNode toJsonNode(Object value) {
try {
return OBJECT_MAPPER.valueToTree(value);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("给定的对象值无法转换为JSON节点: " + value, e);
}
}
public static <T> T toPojo(String content, Class<T> type) {
try {
return OBJECT_MAPPER.readValue(content, type);
} catch (IOException e) {
throw new IllegalArgumentException("给定的字符串值无法转换为指定类型: " + content, e);
}
}
public static <T> T toPojo(String content, TypeReference<T> type) {
try {
return OBJECT_MAPPER.readValue(content, type);
} catch (IOException e) {
throw new IllegalArgumentException("给定的字符串值无法转换为指定类型: " + content, e);
}
}
public static JsonNode toJson(String content) {
try {
return OBJECT_MAPPER.readTree(content);
} catch (IOException e) {
throw new IllegalArgumentException("给定的字符串值无法转换为JSON: " + content, e);
}
}
public static JsonNode toJson(byte[] content) {
try {
return OBJECT_MAPPER.readTree(content);
} catch (IOException e) {
throw new IllegalArgumentException("给定的字节数组无法转换为JSON: " + Arrays.toString(content), e);
}
}
public static <T> T fromJson(JsonNode json, Class<T> type) {
try {
return OBJECT_MAPPER.treeToValue(json, type);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("给定的JSON对象无法转换为指定类型: " + json, e);
}
}
public static <T> T fromJson(String json, Class<T> type) {
try {
return fromJson(toJson(json), type);
} catch (IllegalArgumentException e) {
throw new RuntimeException("JSON转换失败", e);
}
}
public static <T> T treeToValue(JsonNode node, Class<T> clazz) {
try {
return OBJECT_MAPPER.treeToValue(node, clazz);
} catch (IOException e) {
throw new IllegalArgumentException("Can't convert value: " + node.toString(), e);
}
}
public static JsonNode toJsonNode(String value) {
return toJsonNode(value, OBJECT_MAPPER);
}
public static JsonNode toJsonNode(String value, ObjectMapper mapper) {
if (value == null || value.isEmpty()) {
return null;
}
try {
return mapper.readTree(value);
} catch (IOException e) {
throw new IllegalArgumentException(e);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("无法转换值: " + node.toString(), e);
}
}
@@ -171,8 +220,16 @@ public class JacksonUtil {
try {
return OBJECT_MAPPER.writeValueAsBytes(value);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("The given Json object value: "
+ value + " cannot be transformed to a String", e);
throw new IllegalArgumentException("给定的对象值无法转换为字节数组: " + value, e);
}
}
public static <T> void writeValue(Writer writer, T value) {
try {
OBJECT_MAPPER.writeValue(writer, value);
} catch (IOException e) {
throw new IllegalArgumentException("The given writer value: "
+ writer + "cannot be wrote", e);
}
}

View File

@@ -17,7 +17,7 @@ import java.time.format.DateTimeFormatter;
/**
* 时间类型序列化工具
*
* @author baigod
* @author 九筒
*/
public class LocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {
public static final LocalDateTimeSerializer INSTANCE = new LocalDateTimeSerializer();

View File

@@ -17,7 +17,7 @@ import java.time.format.DateTimeFormatter;
/**
* 时间类型序列化工具
*
* @author baigod
* @author 九筒
*/
public class LocalTimeSerializer extends JsonSerializer<LocalTime> {
public static final LocalTimeSerializer INSTANCE = new LocalTimeSerializer();

View File

@@ -16,7 +16,7 @@ import java.time.ZoneId;
/**
* 13位时间戳反序列化器
* @author baigod
* @author 九筒
*/
public class LongTimestampDeserializer extends JsonDeserializer<Long> {

View File

@@ -19,7 +19,7 @@ import java.time.format.DateTimeFormatter;
/**
* sqlDate 反序列化
*
* @author baigod
* @author 九筒
*/
public class SqlDateDeserializer extends JsonDeserializer<Date> {

View File

@@ -17,7 +17,7 @@ import java.sql.Date;
/**
* sqlDate序列化
*
* @author baigod
* @author 九筒
*/
public class SqlDateSerializer extends StdSerializer<Date> {
public static final SqlDateSerializer INSTANCE = new SqlDateSerializer();

View File

@@ -18,7 +18,7 @@ import java.time.format.DateTimeFormatter;
/**
* timestamp 反序列化
* @author baigod
* @author 九筒
*/
public class TimestampDeserializer extends JsonDeserializer<Timestamp> {

View File

@@ -17,7 +17,7 @@ import java.sql.Timestamp;
/**
* timestamp 序列化
*
* @author baigod
* @author 九筒
*/
public class TimestampSerializer extends StdSerializer<Timestamp> {
public static final TimestampSerializer INSTANCE = new TimestampSerializer();

View File

@@ -24,6 +24,8 @@ public @interface Length {
int max() default 255;
int min() default 0;
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};

View File

@@ -0,0 +1,26 @@
/**
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
* 微信mohan_88888
* 抖音:程序员三丙
* 付费课程知识星球https://t.zsxq.com/aKtXo
*/
package sanbing.jcpp.infrastructure.util.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = {})
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface NoNullChar {
String message() default "should not contain 0x00 symbol";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@@ -0,0 +1,31 @@
/**
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
* 微信mohan_88888
* 抖音:程序员三丙
* 付费课程知识星球https://t.zsxq.com/aKtXo
*/
package sanbing.jcpp.infrastructure.util.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
/**
* XSS攻击防护注解
* 用于验证字符串是否包含XSS攻击内容
*
* @author 九筒
*/
@Documented
@Constraint(validatedBy = NoXssValidator.class)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface NoXss {
String message() default "输入包含潜在的XSS攻击内容";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@@ -0,0 +1,61 @@
/**
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
* 微信mohan_88888
* 抖音:程序员三丙
* 付费课程知识星球https://t.zsxq.com/aKtXo
*/
package sanbing.jcpp.infrastructure.util.validation;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import lombok.extern.slf4j.Slf4j;
import java.util.regex.Pattern;
@Slf4j
public class NoXssValidator implements ConstraintValidator<NoXss, Object> {
private static final Pattern JS_TEMPLATE_PATTERN = Pattern.compile("\\{\\{.*}}", Pattern.DOTALL);
// 简化的XSS检查模式避免依赖OWASP AntiSamy
private static final Pattern[] XSS_PATTERNS = {
Pattern.compile("<script[^>]*>.*?</script>", Pattern.CASE_INSENSITIVE | Pattern.DOTALL),
Pattern.compile("javascript:", Pattern.CASE_INSENSITIVE),
Pattern.compile("on\\w+\\s*=", Pattern.CASE_INSENSITIVE),
Pattern.compile("<iframe[^>]*>.*?</iframe>", Pattern.CASE_INSENSITIVE | Pattern.DOTALL),
Pattern.compile("<object[^>]*>.*?</object>", Pattern.CASE_INSENSITIVE | Pattern.DOTALL),
Pattern.compile("<embed[^>]*>.*?</embed>", Pattern.CASE_INSENSITIVE | Pattern.DOTALL),
};
@Override
public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) {
String stringValue;
if (value instanceof CharSequence || value instanceof JsonNode) {
stringValue = value.toString();
} else {
return true;
}
return isValid(stringValue);
}
public static boolean isValid(String stringValue) {
if (stringValue == null || stringValue.isEmpty()) {
return true;
}
// 检查JS模板语法
if (JS_TEMPLATE_PATTERN.matcher(stringValue).find()) {
return false;
}
// 简化的XSS检查
for (Pattern pattern : XSS_PATTERNS) {
if (pattern.matcher(stringValue).find()) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,30 @@
/**
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
* 微信mohan_88888
* 抖音:程序员三丙
* 付费课程知识星球https://t.zsxq.com/aKtXo
*/
package sanbing.jcpp.infrastructure.util.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD})
@Constraint(validatedBy = {})
public @interface RateLimit {
String message() default "rate limit has duplicate 'Per seconds' configuration.";
String fieldName() default "";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@@ -0,0 +1,26 @@
/**
* 开源代码,仅供学习和交流研究使用,商用请联系三丙
* 微信mohan_88888
* 抖音:程序员三丙
* 付费课程知识星球https://t.zsxq.com/aKtXo
*/
package sanbing.jcpp.infrastructure.util.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = {})
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidJsonSchema {
String message() default "must conform to the Draft 2020-12 meta-schema";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}