From e0e794241217d8aa03491836ba4e2aa1c7f93b15 Mon Sep 17 00:00:00 2001
From: Doghole
Date: Thu, 15 May 2025 01:39:54 +0800
Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=96=B9=E6=B3=95=E7=BA=A7?=
=?UTF-8?q?=20Caller=20=E9=94=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../emoney/component/CallerLockAspect.java | 54 ++++++++
.../rich/emoney/component/LockByCaller.java | 20 +++
.../rich/emoney/config/EmoneyAutoConfig.java | 2 +
.../emoney/controller/IndexControllerV1.java | 59 ++++++--
.../config/ProxyConfigControllerV1.java | 28 ++++
.../emoney/entity/config/PlatformConfig.java | 2 +
.../emoney/entity/config/ProxyConfig.java | 12 --
.../rich/emoney/patch/okhttp/PatchOkHttp.java | 1 +
.../quant/rich/emoney/pojo/dto/IpInfo.java | 70 ++++++++++
.../rich/emoney/util/CallerLockUtil.java | 79 +++++++++++
.../quant/rich/emoney/util/GeoIPUtil.java | 127 ++++++++++++++++++
.../META-INF/spring-devtools.properties | 1 +
12 files changed, 430 insertions(+), 25 deletions(-)
create mode 100644 src/main/java/quant/rich/emoney/component/CallerLockAspect.java
create mode 100644 src/main/java/quant/rich/emoney/component/LockByCaller.java
create mode 100644 src/main/java/quant/rich/emoney/controller/config/ProxyConfigControllerV1.java
create mode 100644 src/main/java/quant/rich/emoney/pojo/dto/IpInfo.java
create mode 100644 src/main/java/quant/rich/emoney/util/CallerLockUtil.java
create mode 100644 src/main/java/quant/rich/emoney/util/GeoIPUtil.java
create mode 100644 src/main/resources/META-INF/spring-devtools.properties
diff --git a/src/main/java/quant/rich/emoney/component/CallerLockAspect.java b/src/main/java/quant/rich/emoney/component/CallerLockAspect.java
new file mode 100644
index 0000000..6b4ecce
--- /dev/null
+++ b/src/main/java/quant/rich/emoney/component/CallerLockAspect.java
@@ -0,0 +1,54 @@
+package quant.rich.emoney.component;
+
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.*;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.springframework.expression.*;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.expression.spel.support.StandardEvaluationContext;
+import org.springframework.stereotype.Component;
+
+import quant.rich.emoney.util.CallerLockUtil;
+
+import java.lang.reflect.Method;
+import java.util.concurrent.locks.ReentrantLock;
+
+@Aspect
+@Component
+public class CallerLockAspect {
+
+ private final SpelExpressionParser parser = new SpelExpressionParser();
+
+ @Around("@annotation(com.example.lock.LockByCaller)")
+ public Object around(ProceedingJoinPoint pjp) throws Throwable {
+ MethodSignature signature = (MethodSignature) pjp.getSignature();
+ Method method = signature.getMethod();
+ LockByCaller annotation = method.getAnnotation(LockByCaller.class);
+
+ // 获取 SpEL key(如果有)
+ Object[] args = pjp.getArgs();
+ String[] paramNames = signature.getParameterNames();
+ Object[] extraKey = new Object[0];
+
+ if (!annotation.key().isEmpty()) {
+ EvaluationContext context = new StandardEvaluationContext();
+ for (int i = 0; i < paramNames.length; i++) {
+ context.setVariable(paramNames[i], args[i]);
+ }
+
+ Expression expression = parser.parseExpression(annotation.key());
+ Object value = expression.getValue(context);
+ extraKey = (value == null) ? new Object[0] : new Object[]{value};
+ }
+
+ // 复用工具类的锁逻辑
+ ReentrantLock lock = CallerLockUtil.acquireLock(extraKey);
+
+ lock.lock();
+ try {
+ return pjp.proceed();
+ } finally {
+ lock.unlock();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/quant/rich/emoney/component/LockByCaller.java b/src/main/java/quant/rich/emoney/component/LockByCaller.java
new file mode 100644
index 0000000..9a135f8
--- /dev/null
+++ b/src/main/java/quant/rich/emoney/component/LockByCaller.java
@@ -0,0 +1,20 @@
+package quant.rich.emoney.component;
+
+import java.lang.annotation.*;
+
+/**
+ * 在方法上添加此注解,可针对调用方加锁,即:
+ * 调用方法为 A 的,多次从 A 调用则加锁,从 B 调用时不受影响
+ * 需要开启 AOP:在任意配置类上增加注解:@EnableAspectJAutoProxy
+ * @see CallerLockAspect
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface LockByCaller {
+ /**
+ * 可选参数,用于 SpEL 表达式获取 key
+ * 例如:@LockByCaller(key = "#userId")
+ */
+ String key() default "";
+}
\ No newline at end of file
diff --git a/src/main/java/quant/rich/emoney/config/EmoneyAutoConfig.java b/src/main/java/quant/rich/emoney/config/EmoneyAutoConfig.java
index 70e4c29..7439b7f 100644
--- a/src/main/java/quant/rich/emoney/config/EmoneyAutoConfig.java
+++ b/src/main/java/quant/rich/emoney/config/EmoneyAutoConfig.java
@@ -2,6 +2,7 @@ package quant.rich.emoney.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.context.annotation.Import;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -17,6 +18,7 @@ import quant.rich.emoney.service.ConfigService;
* @author Doghole
*
*/
+@EnableAspectJAutoProxy
@Configuration
@Import(ConfigAutoRegistrar.class)
public class EmoneyAutoConfig implements WebMvcConfigurer {
diff --git a/src/main/java/quant/rich/emoney/controller/IndexControllerV1.java b/src/main/java/quant/rich/emoney/controller/IndexControllerV1.java
index c40357b..f672ff3 100644
--- a/src/main/java/quant/rich/emoney/controller/IndexControllerV1.java
+++ b/src/main/java/quant/rich/emoney/controller/IndexControllerV1.java
@@ -8,11 +8,16 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
import quant.rich.emoney.controller.common.BaseController;
import quant.rich.emoney.entity.config.PlatformConfig;
import quant.rich.emoney.exception.RException;
import quant.rich.emoney.pojo.dto.R;
+import quant.rich.emoney.service.AuthService;
import quant.rich.emoney.service.ConfigService;
+import quant.rich.emoney.util.EncryptUtils;
@Controller
@RequestMapping("/admin/v1")
@@ -23,29 +28,57 @@ public class IndexControllerV1 extends BaseController {
@Autowired
ConfigService configService;
+
+ @Autowired
+ AuthService authService;
@GetMapping({ "", "/", "/index" })
public String index() {
return "admin/v1/index";
}
+ @GetMapping("/getUserInfo")
+ @ResponseBody
+ public R> getUserInfo() {
+ ObjectNode node = new ObjectMapper().valueToTree(platformConfig);
+ node.remove("password");
+ node.remove("isInited");
+ return R.ok(node);
+ }
+
@PostMapping("/changeUserInfo")
@ResponseBody
- public R> changeUserInfo(String newUsername, String oldPassword, String newPassword) {
- if (!platformConfig.getPassword().equals(oldPassword)) {
- throw RException.badRequest("密码错误");
- }
- if (StringUtils.isAllEmpty(newUsername, newPassword)) {
- throw RException.badRequest("未更改任何信息");
- }
- if (StringUtils.isNotEmpty(newUsername)) {
- platformConfig.setUsername(newUsername);
- }
- if (StringUtils.isNotEmpty(newPassword)) {
+ public R> changeUserInfo(
+ String username,
+ String password,
+ String newPassword,
+ String email) {
+
+ if (passwordIsNotEmpty(newPassword)) {
+ if (!platformConfig.getPassword().equals(password)) {
+ throw RException.badRequest("密码错误");
+ }
platformConfig.setPassword(newPassword);
}
- configService.saveOrUpdate(platformConfig);
- return R.ok();
+ if (StringUtils.isNotEmpty(username)) {
+ platformConfig.setUsername(username);
+ }
+ else {
+ throw RException.badRequest("用户名不能为空");
+ }
+ platformConfig.setEmail(email);
+ return R.judge(() -> {
+ if (configService.saveOrUpdate(platformConfig)) {
+ authService.setLogin(username, platformConfig.getPassword());
+ return true;
+ }
+ return false;
+ });
}
+ static final String EMPTY_PASSWORD = EncryptUtils.sha3("", 224);
+
+ static boolean passwordIsNotEmpty(String password) {
+ return StringUtils.isNotEmpty(password) && !password.equalsIgnoreCase(EMPTY_PASSWORD);
+ }
}
diff --git a/src/main/java/quant/rich/emoney/controller/config/ProxyConfigControllerV1.java b/src/main/java/quant/rich/emoney/controller/config/ProxyConfigControllerV1.java
new file mode 100644
index 0000000..a573dc2
--- /dev/null
+++ b/src/main/java/quant/rich/emoney/controller/config/ProxyConfigControllerV1.java
@@ -0,0 +1,28 @@
+package quant.rich.emoney.controller.config;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import lombok.extern.slf4j.Slf4j;
+import quant.rich.emoney.controller.common.BaseController;
+import quant.rich.emoney.entity.config.ProxyConfig;
+import quant.rich.emoney.pojo.dto.R;
+
+@Slf4j
+@Controller
+@RequestMapping("/admin/v1/config/proxy")
+public class ProxyConfigControllerV1 extends BaseController {
+
+ @Autowired
+ ProxyConfig proxyConfig;
+
+
+ @GetMapping("/refreshIpThroughProxy")
+ @ResponseBody
+ public R> refreshIpThroughProxy() {
+ return R.ok(proxyConfig.refreshIpThroughProxy());
+ }
+}
diff --git a/src/main/java/quant/rich/emoney/entity/config/PlatformConfig.java b/src/main/java/quant/rich/emoney/entity/config/PlatformConfig.java
index bad2bfc..3dc8238 100644
--- a/src/main/java/quant/rich/emoney/entity/config/PlatformConfig.java
+++ b/src/main/java/quant/rich/emoney/entity/config/PlatformConfig.java
@@ -13,6 +13,8 @@ public class PlatformConfig implements IConfig {
private String username;
private String password;
+
+ private String email;
private Boolean isInited;
diff --git a/src/main/java/quant/rich/emoney/entity/config/ProxyConfig.java b/src/main/java/quant/rich/emoney/entity/config/ProxyConfig.java
index 1ffa053..06fe540 100644
--- a/src/main/java/quant/rich/emoney/entity/config/ProxyConfig.java
+++ b/src/main/java/quant/rich/emoney/entity/config/ProxyConfig.java
@@ -1,27 +1,15 @@
package quant.rich.emoney.entity.config;
-import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Proxy;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.locks.ReentrantLock;
-
import com.fasterxml.jackson.annotation.JsonView;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.fasterxml.jackson.databind.node.ObjectNode;
-
import lombok.Data;
import lombok.experimental.Accessors;
import lombok.extern.slf4j.Slf4j;
-import okhttp3.OkHttpClient;
-import okhttp3.Request;
-import okhttp3.Response;
-import quant.rich.emoney.client.OkHttpClientProvider;
import quant.rich.emoney.interceptor.EnumOptionsInterceptor.EnumOptions;
import quant.rich.emoney.interfaces.ConfigInfo;
import quant.rich.emoney.interfaces.IConfig;
import quant.rich.emoney.pojo.dto.IpInfo;
-import quant.rich.emoney.util.CallerLockUtil;
import quant.rich.emoney.util.GeoIPUtil;
import quant.rich.emoney.validator.ProxyConfigValid;
diff --git a/src/main/java/quant/rich/emoney/patch/okhttp/PatchOkHttp.java b/src/main/java/quant/rich/emoney/patch/okhttp/PatchOkHttp.java
index d35b71a..305e15d 100644
--- a/src/main/java/quant/rich/emoney/patch/okhttp/PatchOkHttp.java
+++ b/src/main/java/quant/rich/emoney/patch/okhttp/PatchOkHttp.java
@@ -73,6 +73,7 @@ public class PatchOkHttp {
* 在方法体内,使用外部 RestartClassLoader 载入欲重定义的方法类,并在调用 ByteBuddy 时指定该方法类和 ClassLoader。
*
*
+ * 进一步地,如果注入很重要,请判断欲重定义类所属 ClassLoader 和外部 ClassLoader 是否一致,如果不一致的,终止程序。
* @see org.springframework.boot.devtools.restart.classloader.RestartClassLoader
* @see jdk.internal.loader.ClassLoaders
*/
diff --git a/src/main/java/quant/rich/emoney/pojo/dto/IpInfo.java b/src/main/java/quant/rich/emoney/pojo/dto/IpInfo.java
new file mode 100644
index 0000000..a63761c
--- /dev/null
+++ b/src/main/java/quant/rich/emoney/pojo/dto/IpInfo.java
@@ -0,0 +1,70 @@
+package quant.rich.emoney.pojo.dto;
+
+import java.util.Objects;
+
+import org.apache.commons.lang3.StringUtils;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+@Data
+@Accessors(chain=true)
+public class IpInfo {
+ private String ip = "Unknown";
+ private String ipv6;
+ private String country;
+ private String subdivision;
+ private String city;
+
+ public static final IpInfo EMPTY = new IpInfo();
+
+ public IpInfo setIp(String ip) {
+ if (ip != null) this.ip = ip.trim();
+ return this;
+ }
+
+ public IpInfo setIpv6(String ipv6) {
+ if (ipv6 != null) this.ipv6 = ipv6.trim();
+ return this;
+ }
+
+ public String toString() {
+
+ StringBuilder sb = new StringBuilder();
+ sb.append("IP: ").append(getIp());
+ if (StringUtils.isNoneBlank(getIpv6())) {
+ sb.append(", ").append("IPv6: ").append(getIpv6());
+ }
+ sb.append(getGeoString());
+ return sb.toString();
+ }
+
+ public String getString() {
+ return toString();
+ }
+
+ public String getGeoString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("(");
+ if (StringUtils.isNotBlank(getCountry())) {
+ // 国家
+ sb.append(getCountry());
+ if (StringUtils.isNotBlank(getSubdivision())) {
+ sb.append(getSubdivision());
+ }
+ if (StringUtils.isNotBlank(getCity()) && !Objects.equals(getCountry(), getCity())) {
+ // 有时候国家和地区与城市一致的,忽略
+ sb.append(getCity());
+ }
+ }
+ else {
+ sb.append("未知位置");
+ }
+ sb.append(")");
+ return sb.toString();
+ }
+
+ public boolean isEmpty() {
+ return StringUtils.isBlank(getIp());
+ }
+}
diff --git a/src/main/java/quant/rich/emoney/util/CallerLockUtil.java b/src/main/java/quant/rich/emoney/util/CallerLockUtil.java
new file mode 100644
index 0000000..20ff425
--- /dev/null
+++ b/src/main/java/quant/rich/emoney/util/CallerLockUtil.java
@@ -0,0 +1,79 @@
+package quant.rich.emoney.util;
+
+
+import java.lang.StackWalker;
+import java.lang.ref.WeakReference;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.concurrent.*;
+import java.util.concurrent.locks.ReentrantLock;
+
+public class CallerLockUtil {
+
+ private static final StackWalker walker = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE);
+ private static final ConcurrentMap> lockMap = new ConcurrentHashMap<>();
+
+ /**
+ * ✅ 方式一:一键加锁执行
+ */
+ public static void runWithCallerLock(Runnable task, Object... extraKeys) {
+ ReentrantLock lock = acquireLock(extraKeys);
+ lock.lock();
+ try {
+ task.run();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * ✅ 方式二:手动获取锁
+ * 用于:lock() + try/finally + unlock()
+ */
+ public static ReentrantLock acquireLock(Object... extraKeys) {
+ LockKey key = buildLockKey(extraKeys);
+ return lockMap.compute(key, (k, ref) -> {
+ ReentrantLock l = (ref == null || ref.get() == null) ? new ReentrantLock() : ref.get();
+ return new WeakReference<>(l);
+ }).get();
+ }
+
+ /**
+ * 构造调用者方法 + 附加参数为 key
+ */
+ private static LockKey buildLockKey(Object... extraKeys) {
+ String caller = walker.walk(frames ->
+ frames.skip(3).findFirst() // skip getStackTrace → buildLockKey → acquireLock → 调用者
+ .map(f -> f.getClassName() + "#" + f.getMethodName())
+ .orElse("unknown")
+ );
+ return new LockKey(caller, extraKeys);
+ }
+
+ /**
+ * 复合 Key 类(调用方法 + 参数)
+ */
+ private static class LockKey {
+ private final String method;
+ private final Object[] extra;
+ private final int hash;
+
+ LockKey(String method, Object[] extra) {
+ this.method = method;
+ this.extra = (extra == null) ? new Object[0] : extra;
+ this.hash = Objects.hash(method, Arrays.deepHashCode(this.extra));
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (!(obj instanceof LockKey)) return false;
+ LockKey other = (LockKey) obj;
+ return method.equals(other.method) && Arrays.deepEquals(this.extra, other.extra);
+ }
+
+ @Override
+ public int hashCode() {
+ return hash;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/quant/rich/emoney/util/GeoIPUtil.java b/src/main/java/quant/rich/emoney/util/GeoIPUtil.java
new file mode 100644
index 0000000..dc1b31a
--- /dev/null
+++ b/src/main/java/quant/rich/emoney/util/GeoIPUtil.java
@@ -0,0 +1,127 @@
+package quant.rich.emoney.util;
+
+import com.maxmind.geoip2.DatabaseReader;
+import com.maxmind.geoip2.exception.GeoIp2Exception;
+import com.maxmind.geoip2.model.CityResponse;
+import com.maxmind.geoip2.record.Subdivision;
+
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import quant.rich.emoney.client.OkHttpClientProvider;
+import quant.rich.emoney.entity.config.ProxyConfig;
+import quant.rich.emoney.pojo.dto.IpInfo;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.Proxy;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+
+@Slf4j
+public class GeoIPUtil {
+
+ private static DatabaseReader cityReader;
+ public static String LOCALE = "zh-CN";
+
+ static {
+ try {
+ cityReader = new DatabaseReader.Builder(new File("./conf/extra/GeoLite2-City.mmdb")).build();
+ } catch (IOException e) {
+ throw new RuntimeException("IP 地址库初始化失败", e);
+ }
+ }
+
+ public static IpInfo getIpInfoThroughProxy(ProxyConfig proxyConfig) {
+ ReentrantLock lock = CallerLockUtil.acquireLock();
+ lock.lock();
+ try {
+ Proxy proxy = proxyConfig.getProxy();
+ boolean ignoreHttpsVerification = proxyConfig.getIgnoreHttpsVerification();
+ // OkHttp 客户端配置
+ OkHttpClient client = OkHttpClientProvider.getInstance(
+ proxy, ignoreHttpsVerification,
+ builder -> builder
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS));
+
+ // 使用 httpbin.org/ip 获取当前请求的公网 IP
+ Request requestIpv4 = new Request.Builder()
+ .url("https://ipv4.icanhazip.com")
+ .build();
+
+ IpInfo ipInfo = new IpInfo();
+
+ try (Response response = client.newCall(requestIpv4).execute()) {
+ if (!response.isSuccessful()) {
+ log.warn("Request ipv4 failed with code: {}", response.code());
+ return IpInfo.EMPTY;
+ }
+
+ String responseBody = response.body().string();
+ log.debug("Response ipv4 from proxy: {}", responseBody.trim());
+ ipInfo.setIp(responseBody);
+ } catch (IOException e) {
+ log.warn("Proxy ipv4 error: {}", e.getMessage());
+ return IpInfo.EMPTY;
+ }
+ Request requestIpv6 = new Request.Builder()
+ .url("https://ipv6.icanhazip.com")
+ .build();
+ try (Response response = client.newCall(requestIpv6).execute()) {
+ if (!response.isSuccessful()) {
+ log.warn("Request ipv6 failed with code: {}", response.code());
+ }
+
+ String responseBody = response.body().string();
+ log.debug("Response ipv6 from proxy: {}", responseBody.trim());
+ ipInfo.setIpv6(responseBody);
+ } catch (IOException e) {
+ log.warn("Proxy ipv6 error {}", e.getMessage());
+ }
+ return queryIpInfoGeoLite(ipInfo);
+ }
+ finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * 以 IPv4 为准
+ * @param ipInfo
+ */
+ public static IpInfo queryIpInfoGeoLite(IpInfo ipInfo) {
+ try {
+ InetAddress ipv4Address = InetAddress.getByName(ipInfo.getIp());
+ CityResponse response = cityReader.city(ipv4Address);
+
+ if (response.getCountry() != null) {
+ String countryStr = response.getCountry().getNames().get(LOCALE);
+ ipInfo.setCountry(countryStr);
+ }
+
+ if (response.getSubdivisions() != null && !response.getSubdivisions().isEmpty()) {
+ // 省
+ Subdivision subdivision = response.getSubdivisions().get(0);
+ if (subdivision != null) {
+ String subdivisionStr = subdivision.getNames().get(LOCALE);
+ ipInfo.setSubdivision(subdivisionStr);
+ }
+ }
+ if (response.getCity() != null) {
+ // 市
+ String cityStr = response.getCity().getNames().get(LOCALE);
+ ipInfo.setCity(cityStr);
+ }
+ } catch (IOException | GeoIp2Exception e) {
+ log.warn("Get city response from GeoLite2-City database failed: {}", e.getMessage());
+ }
+ return ipInfo;
+ }
+
+ public static void main(String[] args) {
+ System.out.format("GeoLite2-City %s\r\n", queryIpInfoGeoLite(new IpInfo().setIp("46.3.98.216")));
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/META-INF/spring-devtools.properties b/src/main/resources/META-INF/spring-devtools.properties
new file mode 100644
index 0000000..582a033
--- /dev/null
+++ b/src/main/resources/META-INF/spring-devtools.properties
@@ -0,0 +1 @@
+restart.include.okhttp3=.*okhttp-.*\.jar
\ No newline at end of file