diff --git a/src/main/java/quant/rich/emoney/EmoneyAutoApplication.java b/src/main/java/quant/rich/emoney/EmoneyAutoApplication.java index 386d992..5789f3c 100644 --- a/src/main/java/quant/rich/emoney/EmoneyAutoApplication.java +++ b/src/main/java/quant/rich/emoney/EmoneyAutoApplication.java @@ -13,7 +13,8 @@ import org.springframework.scheduling.annotation.EnableScheduling; public class EmoneyAutoApplication { public static void main(String[] args) { - SpringApplication.run(EmoneyAutoApplication.class, args); + SpringApplication app = new SpringApplication(EmoneyAutoApplication.class); + app.run(args); } } diff --git a/src/main/java/quant/rich/emoney/config/SecurityConfig.java b/src/main/java/quant/rich/emoney/config/SecurityConfig.java index 694967e..c7633ed 100644 --- a/src/main/java/quant/rich/emoney/config/SecurityConfig.java +++ b/src/main/java/quant/rich/emoney/config/SecurityConfig.java @@ -29,6 +29,7 @@ public class SecurityConfig { .headers(headers -> headers.cacheControl(cache -> cache.disable())) .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth + .requestMatchers("/favicon.ico").permitAll() .requestMatchers("/admin/*/login").permitAll() .requestMatchers("/admin/*/static/**").permitAll() .requestMatchers("/public/**").permitAll() diff --git a/src/main/java/quant/rich/emoney/config/SqliteMybatisConfig.java b/src/main/java/quant/rich/emoney/config/SqliteMybatisConfig.java index 9e5cf36..6192d25 100644 --- a/src/main/java/quant/rich/emoney/config/SqliteMybatisConfig.java +++ b/src/main/java/quant/rich/emoney/config/SqliteMybatisConfig.java @@ -1,5 +1,11 @@ package quant.rich.emoney.config; +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + import javax.sql.DataSource; import org.apache.ibatis.session.SqlSessionFactory; @@ -8,6 +14,7 @@ import org.mybatis.spring.annotation.MapperScan; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import com.baomidou.mybatisplus.annotation.DbType; @@ -15,16 +22,87 @@ import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.zaxxer.hikari.HikariDataSource; +import lombok.extern.slf4j.Slf4j; +import quant.rich.emoney.EmoneyAutoApplication; + +@Slf4j @Configuration @MapperScan(basePackages = "quant.rich.emoney.mapper.sqlite", sqlSessionTemplateRef = "sqliteSqlSessionTemplate") public class SqliteMybatisConfig { - + + private static final String RESOURCE_PATH = "database.db"; + public static final String SQLITE_TRANSACTION_MANAGER = "sqliteTransactionManager"; + + /** + * 配置数据库连接 + * + * @param dataSource + */ + public void initSQLiteLocation(DataSource dataSource) { + // 指定 sqlite 路径 + if (dataSource instanceof HikariDataSource hikariDataSource) { + + String filePath = hikariDataSource.getJdbcUrl(); + if (filePath == null || !filePath.startsWith("jdbc:sqlite:")) { + log.warn("无法在 SQLite HikariDataSource 中找到合法 SQLite JDBC url, 数据库可能会加载失败。获取到的 jdbc-url: {}", filePath); + return; + } + filePath = filePath.substring("jdbc:sqlite:".length()).trim(); + + ClassPathResource original = new ClassPathResource(RESOURCE_PATH); + if (!original.exists()) { + log.warn("未找到 SQLite 资源: {}", RESOURCE_PATH); + return; + } + String protocol = EmoneyAutoApplication.class.getProtectionDomain().getCodeSource().getLocation().getProtocol(); + boolean isJar = "jar".equals(protocol); + + if (isJar) { + // 复制到外部 yml 指定路径,已存在则不复制 + File dest = new File(filePath), parentDir = dest.getParentFile(); + String destAbsolutePath = dest.getAbsolutePath(); + if (!parentDir.exists() && !parentDir.mkdirs()) { + log.warn("无法创建放置 SQLite 文件的目录: {}", parentDir.getAbsolutePath()); + return; + } + + if (dest.exists()) { + // 已存在 + log.warn("目标资源 {} 已存在,忽略", destAbsolutePath); + return; + } + + try (InputStream in = getClass().getClassLoader().getResourceAsStream(RESOURCE_PATH)) { + if (in == null) { + log.warn("无法读取 SQLite 资源: {}", RESOURCE_PATH); + return; + } + Files.copy(in, dest.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + log.info("SQLite 数据库文件已复制至:{}", destAbsolutePath); + } catch (Exception e) { + log.warn("复制 SQLite 数据库文件失败", e); + } + } + else { + // 使用当前绝对路径 + Path path = Path.of("src/main/resources", RESOURCE_PATH); + hikariDataSource.setJdbcUrl("jdbc:sqlite:" + path.toAbsolutePath().toString()); + } + } + } @Bean("sqliteSqlSessionFactory") public SqlSessionFactory sqliteSqlSessionFactory( @Qualifier("sqliteDataSource") DataSource dataSource) throws Exception { + + initSQLiteLocation(dataSource); + MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); diff --git a/src/main/java/quant/rich/emoney/controller/IndexControllerV1.java b/src/main/java/quant/rich/emoney/controller/IndexControllerV1.java index f672ff3..c856ae7 100644 --- a/src/main/java/quant/rich/emoney/controller/IndexControllerV1.java +++ b/src/main/java/quant/rich/emoney/controller/IndexControllerV1.java @@ -54,7 +54,7 @@ public class IndexControllerV1 extends BaseController { String newPassword, String email) { - if (passwordIsNotEmpty(newPassword)) { + if (EncryptUtils.passwordIsNotEmpty(newPassword)) { if (!platformConfig.getPassword().equals(password)) { throw RException.badRequest("密码错误"); } @@ -75,10 +75,4 @@ public class IndexControllerV1 extends BaseController { 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/LoginControllerV1.java b/src/main/java/quant/rich/emoney/controller/LoginControllerV1.java index 722d650..84e48eb 100644 --- a/src/main/java/quant/rich/emoney/controller/LoginControllerV1.java +++ b/src/main/java/quant/rich/emoney/controller/LoginControllerV1.java @@ -61,7 +61,7 @@ public class LoginControllerV1 extends BaseController { if (Objects.isNull(sessionCaptcha) || !captcha.equalsIgnoreCase(sessionCaptcha.toString())) { throw new LoginException("验证码错误"); } - if (StringUtils.isAnyBlank(username) || !passwordIsNotEmpty(password)) { + if (StringUtils.isAnyBlank(username) || !EncryptUtils.passwordIsNotEmpty(password)) { throw new LoginException("用户名和密码不能为空"); } if (!username.equals(platformConfig.getUsername()) @@ -81,7 +81,7 @@ public class LoginControllerV1 extends BaseController { } // 初始化流程 - if (StringUtils.isAnyBlank(username) || !passwordIsNotEmpty(password)) { + if (StringUtils.isAnyBlank(username) || !EncryptUtils.passwordIsNotEmpty(password)) { throw new LoginException("用户名和密码不能为空"); } platformConfig.setUsername(username).setPassword(password).setIsInited(true); @@ -99,10 +99,4 @@ public class LoginControllerV1 extends BaseController { return "redirect:/admin/v1/login"; } - 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/api/CommonAbilityControllerV1.java b/src/main/java/quant/rich/emoney/controller/api/CommonAbilityControllerV1.java new file mode 100644 index 0000000..4fa0913 --- /dev/null +++ b/src/main/java/quant/rich/emoney/controller/api/CommonAbilityControllerV1.java @@ -0,0 +1,58 @@ +package quant.rich.emoney.controller.api; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.lang.NonNull; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.protobuf.nano.MessageNano; + +import org.apache.commons.lang3.StringUtils; +import org.reflections.Reflections; + +import lombok.extern.slf4j.Slf4j; +import nano.BaseResponse.Base_Response; +import quant.rich.emoney.entity.sqlite.ProtocolMatch; +import quant.rich.emoney.exception.RException; +import quant.rich.emoney.interfaces.IQueryableEnum; +import quant.rich.emoney.pojo.dto.EmoneyConvertResult; +import quant.rich.emoney.pojo.dto.EmoneyProtobufBody; +import quant.rich.emoney.service.sqlite.ProtocolMatchService; + +@RestController +@RequestMapping("/api/v1/common") +@Slf4j +public class CommonAbilityControllerV1 { + + @Autowired + Reflections reflections; + + @GetMapping("/getIQueryableEnum") + public Map getIQueryableEnum(String enumName) { + Set> enums = reflections.getSubTypesOf(IQueryableEnum.class); + Map map = new HashMap<>(); + for (Class clazz : enums) { + if (clazz.getSimpleName().equals(enumName) && clazz.isEnum()) { + @SuppressWarnings({ "unchecked", "rawtypes" }) + Class enumClass = (Class>) clazz; + for (Enum e : enumClass.getEnumConstants()) { + if (e instanceof IQueryableEnum iqe) { + map.put(e.name(), iqe.getNote()); + } + } + break; + } + } + return map; + } + +} diff --git a/src/main/java/quant/rich/emoney/controller/common/BaseController.java b/src/main/java/quant/rich/emoney/controller/common/BaseController.java index e628c6e..affe3b4 100644 --- a/src/main/java/quant/rich/emoney/controller/common/BaseController.java +++ b/src/main/java/quant/rich/emoney/controller/common/BaseController.java @@ -3,9 +3,12 @@ package quant.rich.emoney.controller.common; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; + import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; +import quant.rich.emoney.pojo.dto.R; import quant.rich.emoney.service.AuthService; @Controller @@ -26,5 +29,4 @@ public abstract class BaseController { protected Boolean isLogin() { return authService.isLogin(); } - } diff --git a/src/main/java/quant/rich/emoney/controller/common/UpdateBoolController.java b/src/main/java/quant/rich/emoney/controller/common/UpdateBoolController.java new file mode 100644 index 0000000..52a084c --- /dev/null +++ b/src/main/java/quant/rich/emoney/controller/common/UpdateBoolController.java @@ -0,0 +1,54 @@ +package quant.rich.emoney.controller.common; + +import java.lang.reflect.Field; +import java.util.Optional; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.TableFieldInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.extern.slf4j.Slf4j; +import quant.rich.emoney.exception.RException; +import quant.rich.emoney.pojo.dto.R; + +@Slf4j +public abstract class UpdateBoolController, S extends ServiceImpl> extends BaseController { + + ObjectMapper mapper = new ObjectMapper(); + + protected + R updateBool(S service, Class clazz, SFunction idName, Object idValue, String field, Boolean value) { + TableInfo tableInfo = TableInfoHelper.getTableInfo(clazz); + Object converted = mapper.convertValue(idValue, tableInfo.getKeyType()); + try { + Field declaredField = clazz.getDeclaredField(field); + Optional fieldInfo = tableInfo.getFieldList().stream() + .filter(f -> f.getProperty().equals(field)) + .findFirst(); + if (declaredField.getType().equals(Boolean.class)) { + return R.judge(service.update( + new UpdateWrapper() + .set(fieldInfo.get().getColumn(), value) + .lambda().eq(idName, converted) + ), "更新失败,请查看日志"); + } + } + catch (Exception e) { + log.error("update bool failed", e); + } + throw RException.badRequest().setLogRequest(true); + } + + @PostMapping("/updateBool") + @ResponseBody + abstract protected R updateBool(String id, String field, Boolean value); + +} diff --git a/src/main/java/quant/rich/emoney/controller/manage/PlanControllerV1.java b/src/main/java/quant/rich/emoney/controller/manage/PlanControllerV1.java index 0bd9dd1..c794f91 100644 --- a/src/main/java/quant/rich/emoney/controller/manage/PlanControllerV1.java +++ b/src/main/java/quant/rich/emoney/controller/manage/PlanControllerV1.java @@ -1,11 +1,9 @@ package quant.rich.emoney.controller.manage; -import java.lang.reflect.Field; +import java.io.Serializable; import java.util.Arrays; import java.util.List; import java.util.Objects; -import java.util.Optional; - import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -16,17 +14,15 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; -import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; -import com.baomidou.mybatisplus.core.metadata.TableFieldInfo; -import com.baomidou.mybatisplus.core.metadata.TableInfo; -import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.core.toolkit.StringUtils; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import lombok.extern.slf4j.Slf4j; -import quant.rich.emoney.controller.common.BaseController; +import quant.rich.emoney.controller.common.UpdateBoolController; import quant.rich.emoney.entity.sqlite.Plan; import quant.rich.emoney.exception.RException; +import quant.rich.emoney.interfaces.IQueryableEnum; +import quant.rich.emoney.mapper.sqlite.PlanMapper; import quant.rich.emoney.pojo.dto.LayPageReq; import quant.rich.emoney.pojo.dto.LayPageResp; import quant.rich.emoney.pojo.dto.R; @@ -35,7 +31,7 @@ import quant.rich.emoney.service.sqlite.PlanService; @Slf4j @Controller @RequestMapping("/admin/v1/manage/plan") -public class PlanControllerV1 extends BaseController { +public class PlanControllerV1 extends UpdateBoolController { @Autowired PlanService planService; @@ -79,24 +75,8 @@ public class PlanControllerV1 extends BaseController { @PostMapping("/updateBool") @ResponseBody - public R updateBool(String planId, String field, Boolean value) { - TableInfo tableInfo = TableInfoHelper.getTableInfo(Plan.class); - try { - Field declaredField = Plan.class.getDeclaredField(field); - - Optional fieldInfo = tableInfo.getFieldList().stream() - .filter(f -> f.getProperty().equals(field)) - .findFirst(); - if (declaredField.getType().equals(Boolean.class)) { - planService.update( - new UpdateWrapper() - .eq("plan_id", planId) - .set(fieldInfo.get().getColumn(), value)); - return R.ok(); - } - } - catch (Exception e) {} - throw RException.badRequest(); + public R updateBool(String id, String field, Boolean value) { + return updateBool(planService, Plan.class, Plan::getPlanId, id, field, value); } @PostMapping("/save") @@ -121,14 +101,17 @@ public class PlanControllerV1 extends BaseController { @ResponseBody public R batchOp( @RequestParam(value="ids[]", required=true) - String[] ids, String op) { + String[] ids, PlanBatchOp op) { if (Objects.isNull(ids) || ids.length == 0) { throw RException.badRequest("提供的计划 ID 不能为空"); } List idArray = Arrays.asList(ids); - if (StringUtils.isBlank(op)) { + if (op == null) { // op 为空是删除 + throw RException.badRequest("操作类型不能为空"); + } + else if (PlanBatchOp.DELETE == op) { return R.judge( planService.removeBatchByIds(idArray)); } @@ -136,23 +119,42 @@ public class PlanControllerV1 extends BaseController { LambdaUpdateWrapper uw = new LambdaUpdateWrapper<>(); uw.in(Plan::getPlanId, idArray); - if ("enable".equals(op)) { - uw.set(Plan::getEnabled, true); - } - else if ("disable".equals(op)) { + switch (op) { + case ENABLE: + uw.set(Plan::getEnabled, true); + break; + case DISABLE: uw.set(Plan::getEnabled, false); - } - else if ("enableOpenDayCheck".equals(op)) { + break; + case ENABLE_OPEN_DAY_CHECK: uw.set(Plan::getOpenDayCheck, true); - } - else if ("disableOpenDayCheck".equals(op)) { + break; + case DISABLE_OPEN_DAY_CHECK: uw.set(Plan::getOpenDayCheck, false); + break; + default: + throw RException.badRequest("未知操作"); } - else { - throw RException.badRequest("未识别的操作"); - } - - return R.judge(planService.update(uw)); + return R.judge(() -> planService.update(uw)); } + + private static enum PlanBatchOp implements IQueryableEnum { + DELETE("删除"), + ENABLE("启用"), + DISABLE("停用"), + ENABLE_OPEN_DAY_CHECK("开启交易日校验"), + DISABLE_OPEN_DAY_CHECK("关闭交易日校验"); + + private String note; + + private PlanBatchOp(String note) { + this.note = note; + } + + @Override + public String getNote() { + return note; + } + } } diff --git a/src/main/java/quant/rich/emoney/controller/manage/ProxySettingControllerV1.java b/src/main/java/quant/rich/emoney/controller/manage/ProxySettingControllerV1.java new file mode 100644 index 0000000..f9eb54f --- /dev/null +++ b/src/main/java/quant/rich/emoney/controller/manage/ProxySettingControllerV1.java @@ -0,0 +1,154 @@ +package quant.rich.emoney.controller.manage; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableFieldInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.core.toolkit.StringUtils; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; + +import lombok.extern.slf4j.Slf4j; +import quant.rich.emoney.controller.common.BaseController; +import quant.rich.emoney.entity.sqlite.ProxySetting; +import quant.rich.emoney.exception.RException; +import quant.rich.emoney.pojo.dto.LayPageReq; +import quant.rich.emoney.pojo.dto.LayPageResp; +import quant.rich.emoney.pojo.dto.R; +import quant.rich.emoney.service.sqlite.ProxySettingService; + +@Slf4j +@Controller +@RequestMapping("/admin/v1/manage/proxySetting") +public class ProxySettingControllerV1 extends BaseController { + + @Autowired + ProxySettingService proxySettingService; + + @GetMapping({"", "/", "/index"}) + public String index() { + return "/admin/v1/manage/proxySetting/index"; + } + + @GetMapping("/list") + @ResponseBody + public LayPageResp list(LayPageReq pageReq) { + Page planPage = proxySettingService.page(pageReq); + return new LayPageResp<>(planPage); + } + + @GetMapping("/getOne") + @ResponseBody + public R getOne(String id) { + + // 如果 planId 是空,说明可能希望新建一个 ProxySetting,需要返回默认实例化对象 + if (id == null) { + return R.ok(new ProxySetting()); + } + + // 否则从数据库取 + ProxySetting proxy = proxySettingService.getById(id); + return R.judge(proxy != null, proxy, "无法找到对应 ID 的 ProxySetting"); + } + + @PostMapping("/updateBool") + @ResponseBody + public R updateBool(String id, String field, Boolean value) { + TableInfo tableInfo = TableInfoHelper.getTableInfo(ProxySetting.class); + try { + Field declaredField = ProxySetting.class.getDeclaredField(field); + + Optional fieldInfo = tableInfo.getFieldList().stream() + .filter(f -> f.getProperty().equals(field)) + .findFirst(); + if (declaredField.getType().equals(Boolean.class)) { + proxySettingService.update( + new UpdateWrapper() + .eq("id", id) + .set(fieldInfo.get().getColumn(), value)); + return R.ok(); + } + } + catch (Exception e) {} + throw RException.badRequest(); + } + + @PostMapping("/save") + @ResponseBody + public R save(@RequestBody ProxySetting proxySetting) { + if (!Objects.isNull(proxySetting.getId())) { + proxySettingService.updateById(proxySetting); + } + else { + proxySettingService.save(proxySetting.setId(null)); + } + return R.ok(); + } + + @PostMapping("/delete") + @ResponseBody + public R delete(String id) { + return R.judge(proxySettingService.removeById(id), "删除失败,是否已删除?"); + } + + @PostMapping("/batchOp") + @ResponseBody + public R batchOp( + @RequestParam(value="ids[]", required=true) + String[] ids, ProxySettingBatchOp op) { + if (Objects.isNull(ids) || ids.length == 0) { + throw RException.badRequest("提供的计划 ID 不能为空"); + } + List idArray = Arrays.asList(ids); + + if (op == null) { + // op 为空是删除 + throw RException.badRequest("操作类型不能为空"); + } + else if (ProxySettingBatchOp.DELETE == op) { + return R.judge( + proxySettingService.removeBatchByIds(idArray)); + } + + LambdaUpdateWrapper uw = new LambdaUpdateWrapper<>(); + uw.in(ProxySetting::getId, idArray); + + switch (op) { + case CHECK: + // TODO: 检查连通性 + break; + case DISABLE_HTTPS_VERIFY: + uw.set(ProxySetting::getIgnoreHttpsVerification, false); + break; + case ENABLE_HTTP_VERIFY: + uw.set(ProxySetting::getIgnoreHttpsVerification, true); + break; + default: + throw RException.badRequest("未知操作"); + } + return R.judge(() -> proxySettingService.update(uw)); + } + + private static enum ProxySettingBatchOp { + DELETE, + CHECK, + DISABLE_HTTPS_VERIFY, + ENABLE_HTTP_VERIFY + } + +} diff --git a/src/main/java/quant/rich/emoney/controller/manage/RequestInfoControllerV1.java b/src/main/java/quant/rich/emoney/controller/manage/RequestInfoControllerV1.java new file mode 100644 index 0000000..89a3ff3 --- /dev/null +++ b/src/main/java/quant/rich/emoney/controller/manage/RequestInfoControllerV1.java @@ -0,0 +1,95 @@ +package quant.rich.emoney.controller.manage; + +import java.io.Serializable; +import java.lang.reflect.Field; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableFieldInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; + +import lombok.extern.slf4j.Slf4j; +import quant.rich.emoney.controller.common.BaseController; +import quant.rich.emoney.controller.common.UpdateBoolController; +import quant.rich.emoney.entity.sqlite.RequestInfo; +import quant.rich.emoney.exception.RException; +import quant.rich.emoney.mapper.sqlite.RequestInfoMapper; +import quant.rich.emoney.pojo.dto.LayPageReq; +import quant.rich.emoney.pojo.dto.LayPageResp; +import quant.rich.emoney.pojo.dto.R; +import quant.rich.emoney.service.sqlite.RequestInfoService; + +@Slf4j +@Controller +@RequestMapping("/admin/v1/manage/requestInfo") +public class RequestInfoControllerV1 extends UpdateBoolController { + + @Autowired + RequestInfoService requestInfoService; + + @GetMapping({"", "/", "/index"}) + public String index() { + return "/admin/v1/manage/requestInfo/index"; + } + + @GetMapping("/list") + @ResponseBody + public LayPageResp list(LayPageReq pageReq) { + Page planPage = requestInfoService.page(pageReq); + return new LayPageResp<>(planPage); + } + + @GetMapping("/getOne") + @ResponseBody + public R getOne(Integer id) { + + // 如果 planId 是空,说明可能希望新建一个 Plan,需要返回默认实例化对象 + if (id == null) { + return R.ok(new RequestInfo()); + } + + // 否则从数据库取 + RequestInfo plan = requestInfoService.getById(id); + return R.judge(plan != null, plan, "无法找到对应 ID 的 Plan"); + } + + @PostMapping("/updateBool") + @ResponseBody + @Override + protected R updateBool(String id, String field, Boolean value) { + return updateBool(requestInfoService, RequestInfo.class, RequestInfo::getId, id, field, value); + } + + @PostMapping("/save") + @ResponseBody + public R save(@RequestBody @NonNull RequestInfo plan) { + return R.judge(() -> requestInfoService.saveOrUpdate(plan)); + } + + @PostMapping("/delete") + @ResponseBody + public R delete(String id) { + return R.judge(requestInfoService.removeById(id), "删除失败,是否已删除?"); + } + + @PostMapping("/batchOp") + @ResponseBody + public R batchOp( + @RequestParam(value="ids[]", required=true) + String[] ids, String op) { + return null; + } + +} diff --git a/src/main/java/quant/rich/emoney/entity/sqlite/ProxySetting.java b/src/main/java/quant/rich/emoney/entity/sqlite/ProxySetting.java new file mode 100644 index 0000000..292be50 --- /dev/null +++ b/src/main/java/quant/rich/emoney/entity/sqlite/ProxySetting.java @@ -0,0 +1,79 @@ +package quant.rich.emoney.entity.sqlite; + +import java.net.InetSocketAddress; +import java.net.Proxy; + +import org.apache.commons.lang3.ObjectUtils; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonIgnore; + +import jakarta.annotation.Nonnull; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; +import quant.rich.emoney.validator.ProxySettingValid; + +@Data +@Accessors(chain = true) +@NoArgsConstructor +@TableName(value = "proxy_setting", autoResultMap = true) +@ProxySettingValid +public class ProxySetting { + + @TableId(value="id", type=IdType.AUTO) + private Integer id; + + @Nonnull + private String proxyName; + + private Proxy.Type proxyType = Proxy.Type.DIRECT; + + private String proxyHost = ""; + + private Integer proxyPort = 1; + + private Boolean ignoreHttpsVerification = false; + + /** + * 根据配置获取 java.net.Proxy + * @return + */ + @JsonIgnore + public Proxy getProxy() { + if (getProxyType() != null && getProxyType() != Proxy.Type.DIRECT) { + return new Proxy(getProxyType(), + new InetSocketAddress(getProxyHost(), getProxyPort())); + } + return Proxy.NO_PROXY; + } + + /** + * 根据当前配置获取 ProxyUrl(String) + *

如: + *

  • socks5://127.0.0.1:8888
  • + *
  • http://10.0.0.1:7890
  • + *

    + * @return + */ + public String getProxyUrl() { + if (ObjectUtils.anyNull(getProxyType(), getProxyHost(), getProxyPort())) { + return null; + } + StringBuilder sb = new StringBuilder(); + if (getProxyType() == Proxy.Type.SOCKS) { + sb.append("socks5://"); + } + else if (getProxyType() == Proxy.Type.HTTP) { + sb.append("http://"); + } + else { + return null; + } + sb.append(getProxyHost()).append(':').append(getProxyPort()); + return sb.toString(); + } + +} diff --git a/src/main/java/quant/rich/emoney/entity/sqlite/RequestInfo.java b/src/main/java/quant/rich/emoney/entity/sqlite/RequestInfo.java new file mode 100644 index 0000000..0b0c7e4 --- /dev/null +++ b/src/main/java/quant/rich/emoney/entity/sqlite/RequestInfo.java @@ -0,0 +1,433 @@ +package quant.rich.emoney.entity.sqlite; + +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.AccessLevel; +import lombok.Data; +import lombok.Setter; +import lombok.experimental.Accessors; +import lombok.extern.slf4j.Slf4j; +import quant.rich.emoney.entity.config.AndroidSdkLevelConfig; +import quant.rich.emoney.entity.config.ChromeVersionsConfig; +import quant.rich.emoney.entity.config.DeviceInfoConfig; +import quant.rich.emoney.entity.config.DeviceInfoConfig.DeviceInfo; +import quant.rich.emoney.entity.config.EmoneyRequestConfig; +import quant.rich.emoney.util.EncryptUtils; +import quant.rich.emoney.util.SpringContextHolder; +import quant.rich.emoney.util.TextUtils; + +@Data +@Accessors(chain = true) +@Slf4j +@TableName(value = "request_info") +public class RequestInfo { + + private static AndroidSdkLevelConfig androidSdkLevelConfig = SpringContextHolder.getBean(AndroidSdkLevelConfig.class); + private static DeviceInfoConfig deviceInfoConfig = SpringContextHolder.getBean(DeviceInfoConfig.class); + private static ChromeVersionsConfig chromeVersionsConfig = SpringContextHolder.getBean(ChromeVersionsConfig.class); + + public RequestInfo() { + DeviceInfo deviceInfo = deviceInfoConfig.getRandomDeviceInfo(); + setRelativeFieldsFromDeviceInfo(deviceInfo); + } + + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + /** + * 该请求信息配置的名称,助记用 + */ + private String name = ""; + + /** + * 是否匿名登录 + */ + private Boolean isAnonymous = true; + + /** + * 非匿名登录时的用户名 + */ + private String username = ""; + + /** + * 非匿名登录时的密码 + */ + private String password = ""; + + /** + * 鉴权信息 + */ + private String authorization = ""; + + /** + * UID + */ + private Integer uid = -1; + + /** + * 用于:
      + *
    • 益盟登录接口 guid = MD5(androidId)
    • + *
    • 益盟登录接口 exIdentify.AndroidID = androidId
    • + *
    + * 来源:
    本例随机生成并管理,需要符合 16 位 + * + */ + private String androidId = TextUtils.randomString("abcdef0123456789", 16); + + /** + * 用于:
      + *
    • Webview User-Agent
    • + *
    • Non-Webview Image User-Agent
    • + *
    + * 来源:DeviceInfoConfig + * @see DeviceInfoConfig + */ + @Setter(AccessLevel.PRIVATE) + @TableField(exist=false) + private String androidVersion; + + /** + * 用于:
      + *
    • 益盟通讯接口请求头 X-Android-Agent = EMAPP/{emoneyVersion}(Android;{androidSdkLevel})
    • + *
    • 益盟登录接口 osVersion = androidSdkLevel
    • + *
    + * 来源:DeviceInfoConfig, 经由 AndroidSdkLevelConfig 转换,由本例代管 + * @see DeviceInfoConfig + * @see AndroidSdkLevelConfig + */ + @Setter(AccessLevel.PRIVATE) + @TableField(exist=false) + private String androidSdkLevel; + + /** + * 用于:
      + *
    • 益盟登录接口 softwareType = softwareType
    • + *
    + * 来源:DeviceInfoConfig,由本例代管 + * @see DeviceInfoConfig + */ + private String softwareType; + + /** + * 用于:
      + *
    • 益盟通讯接口请求头 User-Agent = okHttpUserAgent
    • + *
    + * 一般由程序所使用的 OkHttp 版本决定
    + * 来源:本例管理 + */ + private String okHttpUserAgent = "okhttp/3.12.2"; + + /** + * 对应 build.prop 中 Build.MODEL, 用于:
      + *
    • WebView User-Agent
    • + *
    • 非 WebView 图片User-Agent
    • + *
    + * 来源:DeviceInfoConfig, 由本例代为管理 + * @see DeviceInfoConfig + */ + private String deviceName; + + /** + * 对应 build.prop 中 Build.FINGERPRINT, 用于:
      + *
    • 益盟登录接口 hardware = MD5(fingerprint)
    • + *
    • 益盟登录接口 exIdentify.OSFingerPrint = fingerprint
    • + *
    + * 注意最终生成的 exIdentify 是 jsonString, 且有对斜杠的转义
    + * 来源:DeviceInfoConfig, 由本例代为管理 + * @see DeviceInfoConfig + * + */ + private String fingerprint; + + + /** + * 对应 build.prop 中 Build.ID, 用于:
      + *
    • WebView User-Agent
    • + *
    • 非 WebView 图片User-Agent
    • + *
    + * 来源:DeviceInfoConfig, 由本例代为管理 + * @see DeviceInfoConfig + * + */ + @TableField(exist=false) + private String buildId; + + /** + * 用于:
      + *
    • WebView User-Agent
    • + *
    + * 来源:ChromeVersionsConfig, 由本例代为管理 + * @see ChromeVersionsConfig + */ + private String chromeVersion = chromeVersionsConfig.getRandomChromeVersion(); + + /** + * 用于:
      + *
    • 益盟通讯接口请求头 X-Android-Agent = + * EMAPP/{emoneyVersion}(Android;{androidSdkLevel})
    • + *
    + * 由程序版本决定
    + * 来源:本例管理 + * @see EmoneyRequestConfig.androidSdkLevel + */ + private String emoneyVersion = "5.8.1"; + + /** + * 用于:
      + *
    • 益盟通讯接口请求头 Emapp-ViewMode = emappViewMode
    • + *
    + * 由程序决定, 一般默认为 "1"
    + * 来源:本例管理 + */ + private String emappViewMode = "1"; + + /** + * 从 deviceInfo 设置相关字段 + * @param deviceInfo + * @return + */ + public RequestInfo setRelativeFieldsFromDeviceInfo(DeviceInfo deviceInfo) { + if (deviceInfo == null) { + throw new NullPointerException("deviceInfo is null"); + } + deviceName = deviceInfo.getModel(); + androidVersion = deviceInfo.getVersionRelease(); + androidSdkLevel = String.valueOf(androidSdkLevelConfig.getSdkLevel(androidVersion)); + softwareType = deviceInfo.getDeviceType(); + fingerprint = deviceInfo.getFingerprint(); + buildId = deviceInfo.getBuildId(); + return this; + } + + /** + * 设置密码:
      + *
    • null or empty,保存空字符串
    • + *
    • 尝试解密成功,说明是密文,直接保存
    • + *
    • 尝试解密失败,说明是明文,加密保存
    • + *
    + * @param password + * @return + */ + public RequestInfo setPassword(String password) { + if (StringUtils.isEmpty(password)) { + this.password = ""; + return this; + } + String tryDecryptPassword = EncryptUtils.decryptAesForEmoneyPassword(password); + if (tryDecryptPassword != null) { + this.password = password; + } + else { + this.password = EncryptUtils.encryptAesForEmoneyPassword(password); + } + return this; + } + + /** + * 设置 fingerprint,该设置可能会影响相关字段 + * @param fingerprint + * @return + * @see RequestInfo#setRelativeFieldsFromDeviceInfo + */ + public RequestInfo setFingerprint(String fingerprint) { + if (ObjectUtils.allNotNull(deviceName, softwareType, fingerprint, androidSdkLevelConfig)) { + DeviceInfo deviceInfo = DeviceInfo.from(deviceName, fingerprint, softwareType); + setRelativeFieldsFromDeviceInfo(deviceInfo); + } + else { + this.fingerprint = fingerprint; + } + return this; + } + + + /** + * 根据当前配置获取 guid,用于益盟登录接口 + * @return + */ + @JsonIgnore + public String getGuid() { + return EncryptUtils.toMD5String(androidId); + } + + /** + * 一般 Protobuf 请求 X-Android-Agent 头,由 emoneyVersion 和 androidSdkLevel 组成 + * @return + */ + @JsonIgnore + public String getXAndroidAgent() { + // EMAPP/{emoneyVersion}(Android;{androidSdkLevel}) + return + new StringBuilder() + .append("EMAPP/") + .append(getEmoneyVersion()) + .append("(Android;") + .append(getAndroidSdkLevel()) + .append(")").toString(); + } + + /** + * 用于 App 内用到 Webview 的地方 + * @return + */ + @JsonIgnore + public String getWebviewUserAgent() { + return new StringBuilder() + .append("Mozilla/5.0 (Linux; Android ") + .append(getAndroidVersion()) + .append("; ") + .append(getDeviceName()) + .append(" Build/") + .append(getBuildId()) + .append("; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/") + .append(getChromeVersion()) + .append(" Mobile Safari/537.36") + .toString(); + } + + /** + * 用于 App 内少量未用到 Webview 的地方,如首页获取图片等 + * @return + */ + @JsonIgnore + public String getNonWebviewResourceUserAgent() { + // Dalvik/2.1.0 (Linux; U; Android {安卓版本};{Build.DEVICE} Build/{Build.ID}) + return new StringBuilder() + .append("Dalvik/2.1.0 (Linux; U; Android ") + .append(getAndroidVersion()) + .append(";") + .append(getDeviceName()) + .append(" Build/") + .append(getBuildId()) + .append(")") + .toString(); + } + /** + * 根据当前配置获取 hardware,用于益盟登录接口 + * @return + */ + @JsonIgnore + public String getHardware() { + return EncryptUtils.toMD5String(getFingerprint()); + } + + /** + * 根据本例信息(包括保存的用户名和密码)生成一个用于登录的 ObjectNode + * @return + */ + @JsonIgnore + public ObjectNode getUsernamePasswordLoginObject() { + return getUsernamePasswordLoginObject(username, password); + } + + /** + * 根据指定用户名、密码和本例信息生成一个用于登录的 ObjectNode + * @param username 用户名 + * @param password 密码(可以是加密过的,也可以是明文) + * @return + */ + public ObjectNode getUsernamePasswordLoginObject(String username, String password) { + + if (StringUtils.isAnyBlank(username, password)) { + throw new RuntimeException("Try to generate a emoney login object but username and/or password is blank"); + } + + ObjectNode node = getAnonymousLoginObject(); + node.put("accId", username); + node.put("accType", 1); + + // 尝试解密 password 看是否成功,如果成功说明原本就已经是加密了的 + String tryDecryptPassword = EncryptUtils.decryptAesForEmoneyPassword(password); + + node.put("pwd", + tryDecryptPassword != null ? password : + EncryptUtils.encryptAesForEmoneyPassword(password) + ); + + return node; + } + + /** + * 根据本例信息生成一个用于匿名登录的 ObjectNode + * @return + */ + @JsonIgnore + public ObjectNode getAnonymousLoginObject() { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + ObjectNode exIdentify = mapper.createObjectNode(); + exIdentify.put("IMEI", ""); + exIdentify.put("AndroidID", getAndroidId()); + exIdentify.put("MAC", ""); + exIdentify.put("OSFingerPrint", getFingerprint()); + String exIdentifyString = exIdentify.toString().replace("/", "\\/"); + + // 这玩意最好按照顺序来,当前这个顺序是 5.8.1 的顺序 + String guid = getGuid(); + node.put("appVersion", getEmoneyVersion()); + node.put("productId", 4); + node.put("softwareType", getSoftwareType()); + node.put("deviceName", getDeviceName()); + node.put("ssid", "0"); + node.put("platform", "android"); + node.put("exIdentify", exIdentifyString); + node.put("osVersion", getAndroidSdkLevel()); + node.put("accId", guid); + node.put("guid", guid); + node.put("accType", 4); + node.put("pwd", ""); + node.put("channelId", "1711"); + node.put("hardware", getHardware()); + + return node; + } + + /** + * 根据本例信息获取 Relogin ObjectNode + * @return 如果 authorization 和 uid 任意 null 则本例返回 null + */ + @JsonIgnore + public ObjectNode getReloginObject() { + + if (ObjectUtils.anyNull(getAuthorization(), getUid())) { + return null; + } + + ObjectMapper mapper = new ObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + ObjectNode exIdentify = mapper.createObjectNode(); + exIdentify.put("IMEI", ""); + exIdentify.put("AndroidID", getAndroidId()); + exIdentify.put("MAC", ""); + exIdentify.put("OSFingerPrint", getFingerprint()); + String exIdentifyString = exIdentify.toString().replace("/", "\\/"); + + // 这玩意最好按照顺序来,当前这个顺序是 5.8.1 的顺序 + String guid = getGuid(); + node.put("appVersion", getEmoneyVersion()); + node.put("productId", 4); + node.put("softwareType", getSoftwareType()); + node.put("deviceName", getDeviceName()); + node.put("ssid", "0"); + node.put("platform", "android"); + node.put("token", getAuthorization()); // 和登录不同的地方: token + node.put("exIdentify", exIdentifyString); + node.put("uid", getUid()); // 和登录不同的地方: uid + node.put("osVersion", getAndroidSdkLevel()); + node.put("guid", guid); + node.put("channelId", "1711"); + node.put("hardware", getHardware()); + + return node; + } + +} diff --git a/src/main/java/quant/rich/emoney/interceptor/EnumOptionsInterceptor.java b/src/main/java/quant/rich/emoney/interceptor/EnumOptionsInterceptor.java index 2c94c88..5369b6f 100644 --- a/src/main/java/quant/rich/emoney/interceptor/EnumOptionsInterceptor.java +++ b/src/main/java/quant/rich/emoney/interceptor/EnumOptionsInterceptor.java @@ -97,7 +97,15 @@ public class EnumOptionsInterceptor implements HandlerInterceptor { return true; } - + /** + * 将其注解到类型为 enum 的字段上,可以将 enum 注入到 Model 中,供 thymeleaf 使用 + *

    例:某 entity 的某字段为:

    + * private Proxy.Type proxyType; + *

    则在其上注解 @EnumOptions("ProxyTypeEnum"), 即可在 thymeleaf 中直接使用:

    + * ProxyTypeEnum.DIRECT + *

    如果只是注解 @EnumOptions,未指定 value,则在 thymeleaf 为字段名 + Options,上例中便为:

    + * ProxyTypeOptions.DIRECT + */ @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @Documented diff --git a/src/main/java/quant/rich/emoney/interfaces/ConfigInfo.java b/src/main/java/quant/rich/emoney/interfaces/ConfigInfo.java index 3534303..0f9694d 100644 --- a/src/main/java/quant/rich/emoney/interfaces/ConfigInfo.java +++ b/src/main/java/quant/rich/emoney/interfaces/ConfigInfo.java @@ -33,7 +33,7 @@ public @interface ConfigInfo { /** *

    * 为 true 时,最终会调用其无参构造方法,若需要填充默认值的,请在无参构造器中填充。 - * 当为 true 且无法载入配置文件而触发初始化时,若存在 ./conf/system/{field}.fallback.json 文件时,从 fallback + * 当为 true 且无法载入配置文件而触发初始化时,若存在 /conf/system/{field}.fallback.json 文件时,从 fallback * 文件中初始化。fallback 仅参与初始化,不参与持久化 *

    * diff --git a/src/main/java/quant/rich/emoney/interfaces/IQueryableEnum.java b/src/main/java/quant/rich/emoney/interfaces/IQueryableEnum.java new file mode 100644 index 0000000..8cf5024 --- /dev/null +++ b/src/main/java/quant/rich/emoney/interfaces/IQueryableEnum.java @@ -0,0 +1,11 @@ +package quant.rich.emoney.interfaces; + +public interface IQueryableEnum { + + public default String getName() { + return name(); + } + String name(); + public String getNote(); + +} diff --git a/src/main/java/quant/rich/emoney/mapper/sqlite/ProxySettingMapper.java b/src/main/java/quant/rich/emoney/mapper/sqlite/ProxySettingMapper.java new file mode 100644 index 0000000..ac131b3 --- /dev/null +++ b/src/main/java/quant/rich/emoney/mapper/sqlite/ProxySettingMapper.java @@ -0,0 +1,16 @@ +package quant.rich.emoney.mapper.sqlite; + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.stereotype.Component; + +import com.baomidou.dynamic.datasource.annotation.DS; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +import quant.rich.emoney.entity.sqlite.ProxySetting; + +@Component +@Mapper +@DS("sqlite") +public interface ProxySettingMapper extends BaseMapper { + +} diff --git a/src/main/java/quant/rich/emoney/mapper/sqlite/RequestInfoMapper.java b/src/main/java/quant/rich/emoney/mapper/sqlite/RequestInfoMapper.java new file mode 100644 index 0000000..00faf21 --- /dev/null +++ b/src/main/java/quant/rich/emoney/mapper/sqlite/RequestInfoMapper.java @@ -0,0 +1,16 @@ +package quant.rich.emoney.mapper.sqlite; + +import org.apache.ibatis.annotations.Mapper; +import org.springframework.stereotype.Component; + +import com.baomidou.dynamic.datasource.annotation.DS; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +import quant.rich.emoney.entity.sqlite.RequestInfo; + +@Component +@Mapper +@DS("sqlite") +public interface RequestInfoMapper extends BaseMapper { + +} diff --git a/src/main/java/quant/rich/emoney/pojo/dto/IndexDetail.java b/src/main/java/quant/rich/emoney/pojo/dto/IndexDetail.java index 85c2800..b6e9de4 100644 --- a/src/main/java/quant/rich/emoney/pojo/dto/IndexDetail.java +++ b/src/main/java/quant/rich/emoney/pojo/dto/IndexDetail.java @@ -7,6 +7,18 @@ import lombok.experimental.Accessors; public interface IndexDetail { + /** + * 设置原始文本内容 + * @param original + */ + public void setOriginal(String original); + + /** + * 获取原始 json 文本内容 + * @return + */ + public String getOriginal(); + /** * 获取 indexName * @return diff --git a/src/main/java/quant/rich/emoney/pojo/dto/NonParamsIndexDetail.java b/src/main/java/quant/rich/emoney/pojo/dto/NonParamsIndexDetail.java index 910c975..6f59e41 100644 --- a/src/main/java/quant/rich/emoney/pojo/dto/NonParamsIndexDetail.java +++ b/src/main/java/quant/rich/emoney/pojo/dto/NonParamsIndexDetail.java @@ -13,7 +13,6 @@ import lombok.experimental.Accessors; import quant.rich.emoney.util.HtmlSanitizer; @Data -@Accessors(chain=true) public class NonParamsIndexDetail implements IndexDetail { @JsonView(IndexDetail.class) @@ -24,6 +23,8 @@ public class NonParamsIndexDetail implements IndexDetail { private String nameCode; @JsonView(IndexDetail.class) private List data = new ArrayList<>(); + @JsonView(IndexDetail.class) + private String original; @Data @Accessors(chain=true) diff --git a/src/main/java/quant/rich/emoney/pojo/dto/ParamsIndexDetail.java b/src/main/java/quant/rich/emoney/pojo/dto/ParamsIndexDetail.java index e04e356..2f8c653 100644 --- a/src/main/java/quant/rich/emoney/pojo/dto/ParamsIndexDetail.java +++ b/src/main/java/quant/rich/emoney/pojo/dto/ParamsIndexDetail.java @@ -6,11 +6,9 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonView; import lombok.Data; -import lombok.experimental.Accessors; import quant.rich.emoney.util.HtmlSanitizer; @Data -@Accessors(chain=true) public class ParamsIndexDetail implements IndexDetail { @JsonView(IndexDetail.class) @@ -21,15 +19,19 @@ public class ParamsIndexDetail implements IndexDetail { private String code; @JsonView(IndexDetail.class) private List descriptions = new ArrayList<>(); + @JsonView(IndexDetail.class) + private String original; @Override public String getIndexName() { return name; } + @Override public String getIndexCode() { return code; } + @Override public List getDetails() { List list = new ArrayList<>(); @@ -38,6 +40,7 @@ public class ParamsIndexDetail implements IndexDetail { }); return list; } + @Override public void sanitize() { List descriptions = new ArrayList<>(); diff --git a/src/main/java/quant/rich/emoney/service/ConfigService.java b/src/main/java/quant/rich/emoney/service/ConfigService.java index 65f15cb..de85878 100644 --- a/src/main/java/quant/rich/emoney/service/ConfigService.java +++ b/src/main/java/quant/rich/emoney/service/ConfigService.java @@ -1,9 +1,11 @@ package quant.rich.emoney.service; import lombok.extern.slf4j.Slf4j; +import quant.rich.emoney.EmoneyAutoApplication; import quant.rich.emoney.entity.config.SmartViewWriter; import quant.rich.emoney.interfaces.ConfigInfo; import quant.rich.emoney.interfaces.IConfig; +import quant.rich.emoney.util.SmartResourceResolver; import quant.rich.emoney.util.SpringContextHolder; import com.fasterxml.jackson.databind.DeserializationFeature; @@ -11,10 +13,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.BiMap; import com.google.common.collect.HashBiMap; +import io.micrometer.core.instrument.util.IOUtils; import jakarta.annotation.PostConstruct; import java.io.IOException; +import java.io.UncheckedIOException; import java.lang.reflect.InvocationTargetException; +import java.net.URISyntaxException; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; @@ -30,6 +35,7 @@ import org.reflections.scanners.Scanners; import org.reflections.util.ConfigurationBuilder; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; import org.springframework.jmx.export.annotation.ManagedAttribute; import org.springframework.stereotype.Service; @@ -47,6 +53,7 @@ public class ConfigService implements InitializingBean { @Autowired Reflections reflections; + static final boolean isJar = "jar".equals(EmoneyAutoApplication.class.getProtectionDomain().getCodeSource().getLocation().getProtocol()); static final ObjectMapper mapper = new ObjectMapper(); static { @@ -242,11 +249,12 @@ public class ConfigService implements InitializingBean { if (info.save()) { try { String filePath = getConfigFilePath(field, false); - Path dirPath = Paths.get(filePath).getParent(); - if (Files.notExists(dirPath)) { - Files.createDirectories(dirPath); - } - Files.writeString(Path.of(filePath), configJoString); + SmartResourceResolver.saveText(filePath, configJoString); + //Path dirPath = Paths.get(filePath).getParent(); + //if (Files.notExists(dirPath)) { + // Files.createDirectories(dirPath); + //} + //Files.writeString(Path.of(filePath), configJoString); } catch (IOException e) { log.error("Cannot write config to local file {}.json, error: {}", field, e.getMessage()); return false; @@ -258,12 +266,21 @@ public class ConfigService implements InitializingBean { return true; } + /** + * 从指定路径获取配置文件并转换为实例对象 + * @param + * @param path + * @param configClass + * @return + */ private > Config getFromFile(String path, Class configClass) { String configString; Config config = null; - try { - configString = Files.readString(Path.of(path), Charset.defaultCharset()); - } catch (IOException e) { + + try { + // 此处只是读取文件,并不关心该文件是否可写 + configString = IOUtils.toString(SmartResourceResolver.loadResource(path), Charset.defaultCharset()); + } catch (UncheckedIOException e) { String field = fieldClassCache.inverse().get(configClass); log.warn("Cannot read config {}.json: {}", field, e.getMessage()); return config; diff --git a/src/main/java/quant/rich/emoney/service/IndexDetailService.java b/src/main/java/quant/rich/emoney/service/IndexDetailService.java index 11ea5d0..0b5cf8f 100644 --- a/src/main/java/quant/rich/emoney/service/IndexDetailService.java +++ b/src/main/java/quant/rich/emoney/service/IndexDetailService.java @@ -1,9 +1,11 @@ package quant.rich.emoney.service; import java.io.IOException; +import java.io.InputStream; import java.io.Serializable; import java.net.URI; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.text.MessageFormat; @@ -30,6 +32,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.micrometer.core.instrument.util.IOUtils; import lombok.extern.slf4j.Slf4j; import okhttp3.MediaType; import okhttp3.OkHttpClient; @@ -44,7 +47,7 @@ import quant.rich.emoney.pojo.dto.IndexDetail; import quant.rich.emoney.pojo.dto.NonParamsIndexDetail; import quant.rich.emoney.pojo.dto.NonParamsIndexDetail.NonParamsIndexDetailData; import quant.rich.emoney.util.EncryptUtils; -import quant.rich.emoney.util.SpringContextHolder; +import quant.rich.emoney.util.SmartResourceResolver; import quant.rich.emoney.pojo.dto.ParamsIndexDetail; /** @@ -76,13 +79,17 @@ public class IndexDetailService { */ @CacheEvict(cacheNames="@indexDetailService.getIndexDetail(Serializable)", key="#indexCode.toString()") public IndexDetail forceRefreshAndGetIndexDetail(Serializable indexCode) { - Path path = getIndexDetailPath(indexCode); - try { - Files.deleteIfExists(path); - } catch (IOException e) { - String msg = MessageFormat.format("本地 IndexDetail 文件删除失败,path: {0}, msg: {1}", path.toString(), e.getLocalizedMessage()); - throw new RuntimeException(msg, e); + + // 刷新的本质就是从网络获取,因为此处已经清理了缓存,所以直接从网络获取后再 + // 走一次 getIndexDetail,获取到的就是从网络保存到了本地的,此时缓存也更新了 + + if (!hasParams(indexCode)) { + getNonParamsIndexDetailOnline(indexCode); } + else { + getParamsIndexDetailOnline(indexCode); + } + return getIndexDetail(indexCode); } @@ -105,11 +112,11 @@ public class IndexDetailService { private ParamsIndexDetail getParamsIndexDetail(Serializable indexCode) { // 先判断本地有没有 - Path localFilePath = getIndexDetailPath(indexCode); - if (Files.exists(localFilePath)) { + InputStream stream = getIndexDetailStream(indexCode); + if (stream != null) { ParamsIndexDetail detail = null; try { - String str = Files.readString(localFilePath); + String str = IOUtils.toString(stream, StandardCharsets.UTF_8); detail = mapper.readValue(str, ParamsIndexDetail.class); } catch (IOException e) { log.warn("无法获取本地无参数指标说明,将尝试重新从网络获取 indexCode: {}", indexCode, e); @@ -122,6 +129,11 @@ public class IndexDetailService { return getParamsIndexDetailOnline(indexCode); } + /** + * 从网络获取有参指标详情 + * @param indexCode + * @return + */ private ParamsIndexDetail getParamsIndexDetailOnline(Serializable indexCode) { try { @@ -147,13 +159,16 @@ public class IndexDetailService { if (code == 0) { ParamsIndexDetail detail = mapper.treeToValue(result.get("detail"), ParamsIndexDetail.class); if (detail == null) { - // 网络访问成功但为 null, 新建一空 detail + /** 网络访问成功但为 null, 新建一空 detail **/ detail = new ParamsIndexDetail(); detail.setCode(indexCode.toString()); detail.getDescriptions().add("该指标说明接口返回为空"); } - // 清洗内容:凡是文本类型的内容的,都要清洗一遍,判断是否有脚本、 - // 视频和图片等,有的要清除并记录,以免有泄露网址、客户端 IP 风险 + else { + detail.setOriginal(result.get("detail").toString()); + } + /** 清洗内容:凡是文本类型的内容的,都要清洗一遍,判断是否有脚本、 + 视频和图片等,有的要清除并记录,以免有泄露网址、客户端 IP 风险*/ detail.sanitize(); saveIndexDetail(detail); return detail; @@ -180,11 +195,11 @@ public class IndexDetailService { */ private NonParamsIndexDetail getNonParamsIndexDetail(Serializable indexCode) { // 先判断本地有没有 - Path localFilePath = getIndexDetailPath(indexCode); - if (Files.exists(localFilePath)) { + InputStream stream = getIndexDetailStream(indexCode); + if (stream != null) { NonParamsIndexDetail detail = null; try { - String str = Files.readString(localFilePath); + String str = IOUtils.toString(stream, StandardCharsets.UTF_8); detail = mapper.readValue(str, NonParamsIndexDetail.class); } catch (IOException e) { @@ -270,6 +285,9 @@ public class IndexDetailService { .header("Referer", url) .header("Accept-Language", "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7"); List valids = new ArrayList<>(); + + // 循环获取脚本,一旦获取的脚本内正则匹配到包含无参 + // 指标的文本,立即转换为 json、转换指标并结束循环 scriptLoop: for (String scriptUrl : scripts) { Request scriptRequest = scriptBuilder.url(scriptUrl).build(); @@ -298,6 +316,7 @@ public class IndexDetailService { obj.has("nameCode") && obj.get("nameCode").isTextual() && obj.has("data") && obj.get("data").isArray()) { NonParamsIndexDetail detail = mapper.treeToValue(obj, NonParamsIndexDetail.class); + detail.setOriginal(obj.toString()); valids.add(detail); foundAny = true; } @@ -330,7 +349,7 @@ public class IndexDetailService { if (!numericPattern.matcher(detail.getNameCode()).matches()) { continue; } - Path path = getIndexDetailPath(detail); + String path = getIndexDetailPath(detail); // 判断是否是需求的 detail if (indexCode.toString().equals(detail.getIndexCode())) { loadImages(detail); @@ -339,11 +358,25 @@ public class IndexDetailService { // 清洗内容:凡是文本类型的内容的,都要清洗一遍,判断是否有脚本、 // 视频和图片等,有的要清除并记录,以免有泄露网址、客户端 IP 风险 detail.sanitize(); - - if (!Files.exists(path)) { + + InputStream inputStream = SmartResourceResolver.loadResource(path); + if (inputStream == null) { // 不存在则保存 saveIndexDetail(detail); } + else { + // 判断 original 是否一致,不一致则更新 + NonParamsIndexDetail existed; + try { + existed = mapper.readValue(inputStream, NonParamsIndexDetail.class); + if (!existed.getOriginal().equals(detail.getOriginal())) { + saveIndexDetail(detail); + } + } + catch (IOException e) { + log.debug("读取本地存在的 NonParamsIndexDetail 文件成功,但转换失败。格式错误?路径:{}", path, e); + } + } } if (targetDetail == null) { @@ -354,9 +387,9 @@ public class IndexDetailService { List items = List.of("该指标说明接口返回为空"); data.setItems(items); targetDetail.getData().add(data); - Path path = getIndexDetailPath(targetDetail); + String path = getIndexDetailPath(targetDetail); - if (!Files.exists(path)) { + if (SmartResourceResolver.loadResource(path) == null) { // 不存在则保存 saveIndexDetail(targetDetail); } @@ -431,16 +464,16 @@ public class IndexDetailService { } /** - * 保存指标详情到本地文件 + * 保存指标详情到本地文件,无论其原本是否存在 * @param * @param des */ private void saveIndexDetail(Description des) { SmartViewWriter writer = new SmartViewWriter(); String joString = writer.writeWithSmartView(des, IndexDetail.class); - Path path = getIndexDetailPath(des); + String path = getIndexDetailPath(des); try { - Files.writeString(path, joString); + SmartResourceResolver.saveText(path, joString); } catch (IOException e) { log.error("写入指标详情到 {} 失败", path.toString(), e); @@ -453,12 +486,12 @@ public class IndexDetailService { * @param description * @return */ - private Path getIndexDetailPath(Description description) { + private String getIndexDetailPath(Description description) { Path path = Path.of(new StringBuilder(filePath) .append((description instanceof NonParamsIndexDetail) ? "nonParams/": "params/") .append(description.getIndexCode()) .append(".json").toString()); - return path; + return path.normalize().toString(); } /** @@ -466,13 +499,14 @@ public class IndexDetailService { * @param indexCode * @return */ - private Path getIndexDetailPath(Serializable indexCode) { + private InputStream getIndexDetailStream(Serializable indexCode) { boolean hasParams = hasParams(indexCode); - Path path = Path.of(new StringBuilder(filePath) + String path = new StringBuilder(filePath) .append(!hasParams ? "nonParams/": "params/") .append(indexCode) - .append(".json").toString()); - return path; + .append(".json").toString(); + InputStream inputStream = SmartResourceResolver.loadResource(path); + return inputStream; } /** diff --git a/src/main/java/quant/rich/emoney/service/sqlite/ProxySettingService.java b/src/main/java/quant/rich/emoney/service/sqlite/ProxySettingService.java new file mode 100644 index 0000000..8672b2a --- /dev/null +++ b/src/main/java/quant/rich/emoney/service/sqlite/ProxySettingService.java @@ -0,0 +1,12 @@ +package quant.rich.emoney.service.sqlite; + +import org.springframework.stereotype.Service; +import com.baomidou.dynamic.datasource.annotation.DS; +import quant.rich.emoney.entity.sqlite.ProxySetting; +import quant.rich.emoney.mapper.sqlite.ProxySettingMapper; + +@DS("sqlite") +@Service +public class ProxySettingService extends SqliteServiceImpl { + +} diff --git a/src/main/java/quant/rich/emoney/service/sqlite/RequestInfoService.java b/src/main/java/quant/rich/emoney/service/sqlite/RequestInfoService.java new file mode 100644 index 0000000..8fcb10d --- /dev/null +++ b/src/main/java/quant/rich/emoney/service/sqlite/RequestInfoService.java @@ -0,0 +1,14 @@ +package quant.rich.emoney.service.sqlite; + +import org.springframework.stereotype.Service; +import com.baomidou.dynamic.datasource.annotation.DS; + +import quant.rich.emoney.entity.config.DeviceInfoConfig.DeviceInfo; +import quant.rich.emoney.entity.sqlite.RequestInfo; +import quant.rich.emoney.mapper.sqlite.RequestInfoMapper; + +@DS("sqlite") +@Service +public class RequestInfoService extends SqliteServiceImpl { + +} diff --git a/src/main/java/quant/rich/emoney/util/CallerLockUtil.java b/src/main/java/quant/rich/emoney/util/CallerLockUtil.java index 20ff425..72b8838 100644 --- a/src/main/java/quant/rich/emoney/util/CallerLockUtil.java +++ b/src/main/java/quant/rich/emoney/util/CallerLockUtil.java @@ -5,6 +5,7 @@ import java.lang.StackWalker; import java.lang.ref.WeakReference; import java.util.Arrays; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.*; import java.util.concurrent.locks.ReentrantLock; @@ -37,6 +38,44 @@ public class CallerLockUtil { return new WeakReference<>(l); }).get(); } + + /** + * ✅ 方式三:尝试获取锁并运行,支持超时,失败后不阻塞 + * @return true 表示成功执行,false 表示未获得锁 + */ + public static boolean tryRunWithCallerLock(Runnable task, long timeoutMs, Object... extraKeys) { + ReentrantLock lock = acquireLock(extraKeys); + boolean locked = false; + try { + locked = lock.tryLock(timeoutMs, TimeUnit.MILLISECONDS); + if (locked) { + task.run(); + } + return locked; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } finally { + if (locked) lock.unlock(); + } + } + + /** + * ✅ 非阻塞获取锁,超时失败返回 null 或抛异常 + */ + public static Optional tryCallWithCallerLock(Callable task, long timeoutMs, Object... extraKeys) { + ReentrantLock lock = acquireLock(extraKeys); + boolean locked = false; + try { + locked = lock.tryLock(timeoutMs, TimeUnit.MILLISECONDS); + if (!locked) return Optional.empty(); + return Optional.of(task.call()); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + if (locked) lock.unlock(); + } + } /** * 构造调用者方法 + 附加参数为 key diff --git a/src/main/java/quant/rich/emoney/util/EncryptUtils.java b/src/main/java/quant/rich/emoney/util/EncryptUtils.java index aa4bd21..7c595ad 100644 --- a/src/main/java/quant/rich/emoney/util/EncryptUtils.java +++ b/src/main/java/quant/rich/emoney/util/EncryptUtils.java @@ -38,6 +38,16 @@ public class EncryptUtils { private static final String EM_SIGN_MESS_2 = "994fec3c512f2f7756fd5e4403147f01"; private static final String SLASH = "/"; private static final String COLON = ":"; + private static final String EMPTY_SHA3_224 = sha3("", 224); + + /** + * 判断密码是否空字符串,或经过 SHA_224 加密过的空字符串 + * @param password + * @return + */ + public static boolean passwordIsNotEmpty(String password) { + return StringUtils.isNotEmpty(password) && !password.equalsIgnoreCase(EMPTY_SHA3_224); + } /** * 加密用于 Emoney 登录的密码 diff --git a/src/main/java/quant/rich/emoney/util/GeoIPUtil.java b/src/main/java/quant/rich/emoney/util/GeoIPUtil.java index 2471924..725af2f 100644 --- a/src/main/java/quant/rich/emoney/util/GeoIPUtil.java +++ b/src/main/java/quant/rich/emoney/util/GeoIPUtil.java @@ -13,12 +13,12 @@ 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; +import org.springframework.core.io.ClassPathResource; +import org.springframework.scheduling.annotation.Async; @Slf4j public class GeoIPUtil { @@ -28,16 +28,16 @@ public class GeoIPUtil { static { try { - cityReader = new DatabaseReader.Builder(new File("./conf/extra/GeoLite2-City.mmdb")).build(); + ClassPathResource geoLite2CityResource = new ClassPathResource("/conf/extra/GeoLite2-City.mmdb"); + cityReader = new DatabaseReader.Builder(geoLite2CityResource.getInputStream()).build(); } catch (IOException e) { throw new RuntimeException("IP 地址库初始化失败", e); } } + @Async public static IpInfo getIpInfoThroughProxy(ProxyConfig proxyConfig) { - ReentrantLock lock = CallerLockUtil.acquireLock(); - lock.lock(); - try { + return CallerLockUtil.tryCallWithCallerLock(() -> { Proxy proxy = proxyConfig.getProxy(); boolean ignoreHttpsVerification = proxyConfig.getIgnoreHttpsVerification(); // OkHttp 客户端配置 @@ -82,10 +82,7 @@ public class GeoIPUtil { log.warn("Proxy ipv6 error {}", e.getMessage()); } return queryIpInfoGeoLite(ipInfo); - } - finally { - lock.unlock(); - } + }, 100, proxyConfig).orElse(IpInfo.EMPTY); } /** diff --git a/src/main/java/quant/rich/emoney/util/SmartResourceResolver.java b/src/main/java/quant/rich/emoney/util/SmartResourceResolver.java new file mode 100644 index 0000000..71668f8 --- /dev/null +++ b/src/main/java/quant/rich/emoney/util/SmartResourceResolver.java @@ -0,0 +1,111 @@ +package quant.rich.emoney.util; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SmartResourceResolver { + + private static RunningFrom runningFrom; + + static { + if (isRunningFromJar()) { + runningFrom = RunningFrom.JAR; + } + else if (isRunningFromWar()) { + runningFrom = RunningFrom.WAR; + } + else { + runningFrom = RunningFrom.IDE; + } + } + + /** + 获取资源 +

    +

      +
    • JAR +
        +
      • 优先以 jar 文件所在目录为基准,寻找相对路径外部文件
      • +
      • 当外部文件不存在时,读取 classpath,即 jar 内部资源文件
      • +
      +
    • +
    • WAR 只获取 classpath 文件,即 /WEB-INF/classes/ 下文件
    • +
    • IDE 只获取源文件,即 src/main/resources/ 下文件
    • +

    + * @param relativePath 相对路径 + * @param writable 是否一定可写 + * @return + */ + public static InputStream loadResource(String relativePath) { + try { + Path externalPath = resolveExternalPath(relativePath); + + if (externalPath != null && Files.exists(externalPath)) { + log.debug("从外部文件系统加载资源: {}", externalPath); + return Files.newInputStream(externalPath); + } + + // 否则回退到 classpath(JAR、WAR、IDE) + InputStream in = SmartResourceResolver.class.getClassLoader().getResourceAsStream(relativePath); + if (in != null) { + log.debug("从 classpath 内部加载资源: {}", relativePath); + return in; + } + + throw new FileNotFoundException("无法找到资源: " + relativePath); + } catch (Exception e) { + throw new RuntimeException("读取资源失败: " + relativePath, e); + } + } + + public static void saveText(String relativePath, String content) throws IOException { + Path outputPath = resolveExternalPath(relativePath); + Files.createDirectories(outputPath.getParent()); // 确保目录存在 + Files.writeString(outputPath, content, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + log.debug("写入外部资源文件成功: {}", outputPath); + } + + private static Path resolveExternalPath(String relativePath) { + try { + Path basePath; + if (runningFrom == RunningFrom.JAR) { + basePath = Paths.get(SmartResourceResolver.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()).getParent(); + return basePath.resolve(relativePath).normalize(); + } else if (runningFrom == RunningFrom.WAR) { + basePath = Paths.get(SmartResourceResolver.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); // e.g., WEB-INF/classes/ + return basePath.resolve(relativePath).normalize(); + } else { + // IDE 环境:返回 src/main/resources 下真实文件 + return Paths.get("src/main/resources", relativePath).normalize(); + } + } + catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + private static boolean isRunningFromJar() { + String path = SmartResourceResolver.class.getResource( + SmartResourceResolver.class.getSimpleName() + ".class").toString(); + return path.startsWith("jar:"); + } + + private static boolean isRunningFromWar() { + String path = SmartResourceResolver.class.getResource( + SmartResourceResolver.class.getSimpleName() + ".class").toString(); + return path.contains("/WEB-INF/classes/"); + } + + private static enum RunningFrom { + JAR, + WAR, + IDE + } +} diff --git a/src/main/java/quant/rich/emoney/validator/ProxySettingValid.java b/src/main/java/quant/rich/emoney/validator/ProxySettingValid.java new file mode 100644 index 0000000..ad46734 --- /dev/null +++ b/src/main/java/quant/rich/emoney/validator/ProxySettingValid.java @@ -0,0 +1,20 @@ +package quant.rich.emoney.validator; + +import java.lang.annotation.Documented; +import java.lang.annotation.Target; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = ProxySettingValidator.class) +@Target({ ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface ProxySettingValid { + String message() default "非法的 ProxySetting"; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/quant/rich/emoney/validator/ProxySettingValidator.java b/src/main/java/quant/rich/emoney/validator/ProxySettingValidator.java new file mode 100644 index 0000000..798d35f --- /dev/null +++ b/src/main/java/quant/rich/emoney/validator/ProxySettingValidator.java @@ -0,0 +1,33 @@ +package quant.rich.emoney.validator; + +import java.net.Proxy; +import java.util.Objects; +import org.apache.commons.lang3.StringUtils; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import quant.rich.emoney.entity.sqlite.ProxySetting; + +public class ProxySettingValidator implements IValidator, ConstraintValidator { + + @Override + public boolean isValid(ProxySetting value, ConstraintValidatorContext context) { + + if (value == null) return true; + if (!(value instanceof ProxySetting proxySetting)) return true; + + if (proxySetting.getProxyType() != null && proxySetting.getProxyType() != Proxy.Type.DIRECT) { + if (StringUtils.isBlank(proxySetting.getProxyHost())) { + return invalid(context, "设置代理为 HTTP 或 SOCKS 时,代理地址不允许为空"); + } + if (Objects.isNull(proxySetting.getProxyPort()) || proxySetting.getProxyPort() <= 0 || proxySetting.getProxyPort() > 65535) { + return invalid(context, "端口不合法"); + } + // 不做连通性校验:有的代理可能先添加后生效 + } + + return true; + + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 11b8b39..34a2fd6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -19,7 +19,10 @@ spring: devtools: restart: enabled: true - additional-exclude: '**/*.html' + additional-exclude: + - '**/*.html' + - '**/*.js' + - '**/*.css' additional-paths: lib/ jackson: date-format: yyyy-MM-dd HH:mm:ss diff --git a/src/main/resources/database.db b/src/main/resources/database.db index 7ba0eee..c469ec3 100644 Binary files a/src/main/resources/database.db and b/src/main/resources/database.db differ diff --git a/src/main/resources/static/admin/v1/static/css/admin.css b/src/main/resources/static/admin/v1/static/css/admin.css index 7a4a523..bb1ebbf 100644 --- a/src/main/resources/static/admin/v1/static/css/admin.css +++ b/src/main/resources/static/admin/v1/static/css/admin.css @@ -382,6 +382,7 @@ blockquote.layui-elem-quote { bottom: 0; box-shadow: 1px 1px 10px rgba(0, 0, 0, .1); border-radius: 0; + overflow: auto } .layui-layer-indexDetail>.layui-layer-content>*:not(:last-child) { margin-bottom: 1em; @@ -394,7 +395,7 @@ blockquote.layui-elem-quote { display: block } .layui-layer-adminRight>.layui-layer-content { - overflow: visible !important; + /* overflow: visible !important; */ } .layui-anim-rl { -webkit-animation-name: layui-rl; diff --git a/src/main/resources/static/admin/v1/static/js/dog.js b/src/main/resources/static/admin/v1/static/js/dog.js index 6070294..0fe6281 100644 --- a/src/main/resources/static/admin/v1/static/js/dog.js +++ b/src/main/resources/static/admin/v1/static/js/dog.js @@ -1,6 +1,12 @@ function InitDog() { const dog = {}; - dog.error = ({msg = '服务器错误', time = 2000, onClose}) => { + dog.error = ({msg = '服务器错误', defaultMsg = '服务器错误', time = 2000, onClose}) => { + if (typeof msg === 'object') { + if (msg.responseJSON) { + const r = msg.responseJSON; + msg = r && r.data || defaultMsg + } + } return layui.layer.msg( '' + msg, { offset: '15px', @@ -10,7 +16,7 @@ function InitDog() { end: onClose }) }; - dog.success = ({msg = '操作成功', time = 2000, onClose}) => { + dog.success = ({msg = '操作成功', defaultMsg = '操作成功', time = 2000, onClose}) => { return layui.layer.msg( '' + msg, { offset: '15px', @@ -19,8 +25,17 @@ function InitDog() { skin: 'dog success', end: onClose }) + }; + dog.reloadTable = (tableFilter) => { + if (!tableFilter) { + tableFilter = document.querySelector('table[lay-filter]').getAttribute('lay-filter'); + } + layui.table.reload(tableFilter, { + page: { + curr: $('.layui-laypage-em').next().html() + } + }) } - return dog; } const Dog = window.Dog = InitDog(); \ No newline at end of file diff --git a/src/main/resources/static/admin/v1/static/js/helper.js b/src/main/resources/static/admin/v1/static/js/helper.js index 2fccc51..b68453c 100644 --- a/src/main/resources/static/admin/v1/static/js/helper.js +++ b/src/main/resources/static/admin/v1/static/js/helper.js @@ -1,14 +1,6 @@ if (!window.Helper) { window.Helper = {} } window.Helper = { - emoneyPeriodToName: function(x) { - if (x < 10000) return `${x} 分钟`; - if (x == 10000) return '日线'; - if (x == 20000) return '周线'; - if (x == 30000) return '月线'; - if (x == 40000) return '季线'; - if (x == 50000) return '半年线'; - if (x == 60000) return '年线'; - }, + emoneyPeriodToName: x => x < 10000 && `${x} 分钟线` || [null, '日线', '周线', '月线', '季线', '半年线', '年线'][x/10000], allEmoneyPeriods: [1, 5, 15, 30, 60, 120, 10000, 20000, 30000, 40000, 50000, 60000], showIndexDetailLayer: async function(obj, forceRefresh) { // obj: {indexCode: _, indexName: _} @@ -50,14 +42,12 @@ window.Helper = { skin: 'layui-layer-indexDetail', area: ['520px', '320px'], btn: ['刷新', '确定'], - btn1: function(index, layero, that) { + btn1: function(index, _, _) { layer.close(index); Helper.showIndexDetailLayer(obj, !0); }, - success: function(layero, index) { - var btns = layero.find('.layui-layer-btn>*'); - btns[0].setAttribute('class', 'layui-layer-btn1'); - btns[1].setAttribute('class', 'layui-layer-btn0'); + success: function(layero, _) { + Helper.setLayerMainBtn(layero, -1); } }) } @@ -70,6 +60,211 @@ window.Helper = { const escaped = chars.split('').map(c => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join(''); const pattern = new RegExp(`^[${escaped}]+|[${escaped}]+$`, 'g'); return str.replace(pattern, ''); - } + }, + setLayerMainBtn: function (layero, index) { + var btns = layero.find('.layui-layer-btn>*'), j = 1; + if (index < 0) index = btns.length + index; + for (let i = 0; i < btns.length; i++) { + const btn = btns[i]; + const clazz = btn.getAttribute('class'); + const classes = clazz.split(' '); + let filtered = classes.filter(str => !/^layui\-layer\-btn/gi.test(str)); + filtered.push('layui-layer-btn' + (index == i ? '0' : j++)); + btn.setAttribute('class', filtered.join(' ')); + } + }, + openR: function (option) { + const defaultOption = { + type: 1, area: '500px', + skin: 'layui-anim layui-anim-rl layui-layer-adminRight', + anim: -1, shadeClose: !0, closeBtn: !0, move: !1, offset: 'r' + }; + option = $.extend(defaultOption, option); + return layui.layer.open(option) + }, + /** + * 按照通用配置来渲染表格 + * option: 和 table.render 选项基本一致, 但需要额外提供: + * idName: 该表格行对象 id 的名称 + * baseUrl: 当前基本 URL, 将在此基础上拼接 list/updateBool/batchOp 等内容 + * batchOpEnum: 批量操作枚举名,若不提供则不渲染批量操作控件和回调逻辑,若为 true 则仅渲染批量删除逻辑 + * 除此以外,cols 内列如果需要开关选项的, 在相应 col 内添加 switchTemplet: true + */ + renderTable: (option) => { + const defaultOption = { + page: !0, skin: 'line' + }; + option = $.extend(defaultOption, option); + if (!option.idName) throw new Error('idName 不允许为空'); + if (!option.baseUrl) throw new Error('baseUrl 不允许为空'); + if (!option.baseUrl.endsWith('/')) option.baseUrl += '/'; + option.url = option.baseUrl + 'list'; + let tableSwitchTemplet = function () { + // 以 elem 选择器 + '.' + switchFilter 作为 filter + const filter = `${option.elem}.switchFilter`; + layui.form.on(`switch(${filter})`, function (obj) { + console.log(obj, obj.elem.checked); + const data = { + field: obj.elem.dataset.field, + value: obj.elem.checked, + id: obj.elem.dataset.id + }; + $.ajax({ + url: option.baseUrl + 'updateBool', method: 'POST', + data:data, + success: () => Dog.success({time: 1000}), + error: function (res) { + Dog.error({msg: res}) + // 恢复 enabled 状态 + obj.elem.checked = !obj.elem.checked; + layui.form.render('checkbox') + return + } + }) + }); + return d => { + var fieldName = d.LAY_COL.field; + return ``; + } + } + let cols = option.cols[0]; + cols.forEach(col => { + if (col.switchTemplet) { + col.templet = tableSwitchTemplet() + } + }) + layui.table.render(option) + }, + onSubmitForm: (submitButtonFilter, func) => { + layui.form.on(`submit(${submitButtonFilter})`, _ => { + const buttonEl = $(`[lay-filter="${submitButtonFilter}"]`); + if (!buttonEl.length) { + Dog.error({msg: `找不到对应 filter 为 ${submitButtonFilter} 的 button`}); + return + } + const form = $(buttonEl.parents('.layui-form')[0] || buttonEl.parents('form')[0]); + if (!form.length) { + Dog.error({msg: `找不到对应 filter 为 ${submitButtonFilter} 的 button 对应的表单`}); + return + } + // 获取 form 内所有表单 + const els = form.find('input[name], select[name], textarea[name], button[name]'); + let obj = {form: form[0], field: {}}; + $.each(els, (i, el) => { + const name = el.name; + if (!name) return true + switch (el.type) { + case 'checkbox': + if (el.getAttribute('lay-skin')) { + // 带 skin 当做二值简单表单 + obj.field[name] = el.value; + } + else { + if (!obj.field.hasOwnProperty(name)) { + obj.field[name] = []; + } + if (el.checked) { + obj.field[name].push(el.value); + } + } + break; + case 'radio': + if (el.checked) { + obj.field[name] = el.value; + } + break; + + case 'select-multiple': + obj.field[name] = Array.from(el.selectedOptions).map(opt => opt.value); + break; + + default: + obj.field[name] = el.value; + } + }); + if (func && typeof func === 'function') return func(obj); + else if (func && typeof func === 'string') { + // 按照默认来 post + $.ajax({ + url: func, + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(obj.field), + success: function (r) { + Dog.success({onClose: () => { + if (window.editLayer) layui.layer.close(window.editLayer); + Dog.reloadTable()}}) + }, + error: res => Dog.error({msg: res}), + }); + } + }) + }, + fillEditForm: (r, layero, layerIndex, extraSwitchFuncs) => { + const el = $(layero); + let switchFuncs = []; + for (let key in r.data) { + let val = r.data[key]; + const fieldEl = el[0].querySelector(`[name="${key}"]`); + if (!fieldEl) continue; + const type = fieldEl.type; + switch (type) { + case 'checkbox': + const checked = fieldEl.value = fieldEl.chceked = val == 'true' || val == true; + const laySkin = fieldEl.getAttribute('lay-skin'); + if (laySkin) { + switchFuncs[key] = function (obj) { + obj.elem.value = obj.elem.checked; + layui.form.render(); + } + layui.form.on(`switch(${key})`, function (obj) { + switchFuncs[obj.elem.name](obj); + }) + layui.event.call(this, 'form', `switch(${key})`, { + elem: fieldEl, + value: checked + }); + } + break; + case 'radio': + fieldEl.value = fieldEl.chceked = val == 'true' || val == true; + break; + case 'select-one': + const options = fieldEl.querySelectorAll('option'); + options.forEach(option => { + if (option.value == val) { + option.selected = true; + return false; + } + }) + break; + default: + fieldEl.value = val + } + if (type === 'text' || type === 'password' || type === 'hidden') { + fieldEl.value = val; + } + } + if (extraSwitchFuncs) { + switchFuncs = $.extend(switchFuncs, extraSwitchFuncs) + } + }, + tableSwitchTemplet: idName => { + layui.form.on('switch(switchFilter)', function (obj) { + console.log(obj, obj.elem.checked); + $.ajax({ + + }) + }) + return d => { + var fieldName = d.LAY_COL.field; + return ``; + } + }, + randomHexString: n => [...Array(n)].map(() => 'abcdef0123456789'[~~(Math.random() * 16)]).join('') } \ No newline at end of file diff --git a/src/main/resources/static/img/emograb_logo.webp b/src/main/resources/static/img/emograb_logo.webp new file mode 100644 index 0000000..2d5bfc3 Binary files /dev/null and b/src/main/resources/static/img/emograb_logo.webp differ diff --git a/src/main/resources/webpage/admin/v1/include.html b/src/main/resources/webpage/admin/v1/include.html index 4c7882c..bbe3a81 100644 --- a/src/main/resources/webpage/admin/v1/include.html +++ b/src/main/resources/webpage/admin/v1/include.html @@ -2,81 +2,132 @@ - - - [[${(title!=null?title:'后台管理')}]] - - - - - - - - - - - - + +[[${(title!=null?title:'后台管理')}]] + + + + + + + + + +