From 9ff37e00205c776f62a9b07e1f9d8736a14d4d8c Mon Sep 17 00:00:00 2001
From: Administrator <15274802129@163.com>
Date: Wed, 08 Jul 2026 11:50:41 +0800
Subject: [PATCH] feat(gate): 添加 Gate 策略控制面板和配置管理功能
---
src/main/resources/static/gate-config.html | 263 +++++++++++++++++
src/main/java/com/xcong/excoin/modules/gateApi/GateConfigDTO.java | 80 +++++
src/main/java/com/xcong/excoin/modules/gateApi/GateConfigPersistenceService.java | 135 +++++++++
src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientManager.java | 146 ++++++--
src/main/java/com/xcong/excoin/modules/gateApi/GateConfigController.java | 133 ++++++++
src/main/java/com/xcong/excoin/modules/gateApi/GateLogBuffer.java | 86 +++++
src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java | 2
7 files changed, 802 insertions(+), 43 deletions(-)
diff --git a/src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java b/src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java
index dfe6523..68196a0 100644
--- a/src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java
+++ b/src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java
@@ -53,6 +53,8 @@
.antMatchers("/api/member/getAppVersionInfo").permitAll()
.antMatchers("/api/orderCoin/searchSymbolResultList").permitAll()
.antMatchers("/api/orderCoin/findCollect").permitAll()
+ .antMatchers("/api/gate/**").permitAll()
+ .antMatchers("/gate-config.html").permitAll()
.anyRequest().authenticated()
.and().apply(securityConfiguereAdapter());
}
diff --git a/src/main/java/com/xcong/excoin/modules/gateApi/GateConfigController.java b/src/main/java/com/xcong/excoin/modules/gateApi/GateConfigController.java
new file mode 100644
index 0000000..bd9d90c
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/gateApi/GateConfigController.java
@@ -0,0 +1,133 @@
+package com.xcong.excoin.modules.gateApi;
+
+import com.xcong.excoin.common.response.Result;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+/**
+ * Gate 配置控制面板 REST 接口。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/gate")
+@Api(tags = "Gate配置管理")
+public class GateConfigController {
+
+ @Autowired
+ private GateWebSocketClientManager clientManager;
+
+ @Autowired
+ private GateConfigPersistenceService persistenceService;
+
+ @Autowired
+ private GateLogBuffer logBuffer;
+
+ /**
+ * 根据 apiKey 加载配置,不存在则返回空对象让前端用默认值填充。
+ */
+ @PostMapping("/config/load")
+ @ApiOperation("根据 apiKey 加载配置")
+ public Result loadConfig(@RequestParam String apiKey) {
+ if (apiKey == null || apiKey.trim().isEmpty()) {
+ return Result.fail("apiKey 不能为空");
+ }
+ GateConfigDTO dto = persistenceService.load(apiKey);
+ if (dto == null) {
+ dto = new GateConfigDTO();
+ }
+ dto.setApiKey(apiKey);
+ return Result.ok(dto);
+ }
+
+ /**
+ * 获取当前运行中的配置。
+ */
+ @GetMapping("/config")
+ @ApiOperation("获取当前运行策略的配置")
+ public Result getConfig() {
+ GateConfig config = clientManager.getConfig();
+ if (config == null) {
+ return Result.fail("策略未初始化");
+ }
+ return Result.ok(GateConfigDTO.from(config));
+ }
+
+ /**
+ * 保存配置到文件。
+ */
+ @PostMapping("/config")
+ @ApiOperation("保存配置")
+ public Result saveConfig(@RequestBody GateConfigDTO dto) {
+ if (dto.getApiKey() == null || dto.getApiKey().trim().isEmpty()) {
+ return Result.fail("apiKey 不能为空");
+ }
+ persistenceService.save(dto.getApiKey(), dto);
+ return Result.ok("配置已保存,需要重启策略才能生效");
+ }
+
+ /**
+ * 重启网格策略:停止 → 用指定 apiKey 的配置重新初始化。
+ */
+ @PostMapping("/restart")
+ @ApiOperation("重启策略")
+ public Result restart(@RequestParam String apiKey) {
+ if (apiKey == null || apiKey.trim().isEmpty()) {
+ return Result.fail("apiKey 不能为空");
+ }
+ try {
+ GateConfigDTO dto = persistenceService.load(apiKey);
+ if (dto == null) {
+ return Result.fail("未找到该 apiKey 的配置文件,请先保存配置");
+ }
+ GateConfig newConfig = persistenceService.buildFromDTO(dto, apiKey);
+ clientManager.restartWithConfig(newConfig);
+ logBuffer.info("策略已使用 apiKey=" + maskKey(apiKey) + " 的配置重启");
+ return Result.ok("策略已使用新配置重启");
+ } catch (Exception e) {
+ log.error("[GateConfig] 重启策略失败", e);
+ return Result.fail("重启失败: " + e.getMessage());
+ }
+ }
+
+ /**
+ * 获取策略运行状态。
+ */
+ @GetMapping("/status")
+ @ApiOperation("获取策略状态")
+ public Result getStatus() {
+ GateGridTradeService service = clientManager.getGridTradeService();
+ if (service == null) {
+ return Result.ok("STOPPED");
+ }
+ return Result.ok(service.isStrategyActive() ? "ACTIVE" : "STOPPED");
+ }
+
+ /**
+ * 停止策略。
+ */
+ @PostMapping("/stop")
+ @ApiOperation("停止策略")
+ public Result stop() {
+ clientManager.stopStrategy();
+ return Result.ok("策略已停止");
+ }
+
+ /**
+ * SSE 日志流。
+ */
+ @GetMapping(value = "/logs", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+ @ApiOperation("实时日志 SSE 流")
+ public SseEmitter streamLogs() {
+ return logBuffer.subscribe();
+ }
+
+ private static String maskKey(String key) {
+ if (key == null || key.length() <= 8) return "***";
+ return key.substring(0, 4) + "****" + key.substring(key.length() - 4);
+ }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/gateApi/GateConfigDTO.java b/src/main/java/com/xcong/excoin/modules/gateApi/GateConfigDTO.java
new file mode 100644
index 0000000..0c7fa57
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/gateApi/GateConfigDTO.java
@@ -0,0 +1,80 @@
+package com.xcong.excoin.modules.gateApi;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+/**
+ * Gate 策略配置 DTO,用于 Web 控制面板参数传递。
+ * apiSecret 不暴露给前端。
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class GateConfigDTO {
+
+ /** API Key */
+ private String apiKey;
+ /** 合约名称,如 ETH_USDT */
+ private String contract;
+ /** 杠杆倍数 */
+ private String leverage;
+ /** 保证金模式:cross / isolated */
+ private String marginMode;
+ /** 持仓模式:single / dual */
+ private String positionMode;
+ /** 网格间距比例 */
+ private BigDecimal gridRate;
+ /** 预期收益(USDT) */
+ private BigDecimal expectedProfit;
+ /** 最大亏损(USDT) */
+ private BigDecimal maxLoss;
+ /** 基底开仓张数 */
+ private String baseQuantity;
+ /** 每次下单张数 */
+ private String quantity;
+ /** 最大持仓张数(单方向),0=不限制 */
+ private int maxPositionSize;
+ /** 止损阶梯次数,0=禁用 */
+ private int stopLossCount;
+ /** 策略重启跨度阈值,0=禁用 */
+ private int restartGridSpan;
+ /** 价格精度 */
+ private int priceScale;
+ /** 合约乘数 */
+ private BigDecimal contractMultiplier;
+ /** 未实现盈亏计价模式:LAST_PRICE / MARK_PRICE */
+ private String unrealizedPnlPriceMode;
+ /** 是否为生产环境 */
+ private boolean isProduction;
+
+ /**
+ * 从 GateConfig 构建 DTO(不暴露 apiSecret)。
+ */
+ public static GateConfigDTO from(GateConfig config) {
+ return GateConfigDTO.builder()
+ .apiKey(config.getApiKey())
+ .contract(config.getContract())
+ .leverage(config.getLeverage())
+ .marginMode(config.getMarginMode())
+ .positionMode(config.getPositionMode())
+ .gridRate(config.getGridRate())
+ .expectedProfit(config.getExpectedProfit())
+ .maxLoss(config.getMaxLoss())
+ .baseQuantity(config.getBaseQuantity())
+ .quantity(config.getQuantity())
+ .maxPositionSize(config.getMaxPositionSize())
+ .stopLossCount(config.getStopLossCount())
+ .restartGridSpan(config.getRestartGridSpan())
+ .priceScale(config.getPriceScale())
+ .contractMultiplier(config.getContractMultiplier())
+ .unrealizedPnlPriceMode(config.getUnrealizedPnlPriceMode() != null
+ ? config.getUnrealizedPnlPriceMode().name() : "LAST_PRICE")
+ .isProduction(config.isProduction())
+ .build();
+ }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/gateApi/GateConfigPersistenceService.java b/src/main/java/com/xcong/excoin/modules/gateApi/GateConfigPersistenceService.java
new file mode 100644
index 0000000..480b87c
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/gateApi/GateConfigPersistenceService.java
@@ -0,0 +1,135 @@
+package com.xcong.excoin.modules.gateApi;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.PostConstruct;
+import java.io.File;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * Gate 配置持久化服务 — 按 apiKey 分文件存储。
+ * 文件路径: config/gate-config-{md5(apiKey)}.json
+ */
+@Slf4j
+@Service
+public class GateConfigPersistenceService {
+
+ private static final String CONFIG_DIR = "config";
+ private static final String CONFIG_PREFIX = "gate-config-";
+ private static final String JSON_SUFFIX = ".json";
+
+ private final ObjectMapper mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
+
+ @PostConstruct
+ public void ensureConfigDir() {
+ File dir = new File(CONFIG_DIR);
+ if (!dir.exists()) {
+ dir.mkdirs();
+ }
+ }
+
+ /**
+ * 确保指定 apiKey 的配置文件存在,不存在则用默认值创建。
+ */
+ public GateConfigDTO ensureExists(String apiKey, GateConfigDTO defaults) {
+ GateConfigDTO existing = load(apiKey);
+ if (existing != null) {
+ return existing;
+ }
+ save(apiKey, defaults);
+ log.info("[GateConfig] 为 apiKey={} 创建默认配置文件", maskKey(apiKey));
+ return defaults;
+ }
+
+ /**
+ * 保存配置。
+ */
+ public void save(String apiKey, GateConfigDTO dto) {
+ try {
+ mapper.writeValue(configFile(apiKey), dto);
+ log.info("[GateConfig] 配置已保存, apiKey={}", maskKey(apiKey));
+ } catch (IOException e) {
+ log.error("[GateConfig] 保存配置失败, apiKey={}", maskKey(apiKey), e);
+ throw new RuntimeException("保存配置失败", e);
+ }
+ }
+
+ /**
+ * 加载配置,文件不存在返回 null。
+ */
+ public GateConfigDTO load(String apiKey) {
+ File file = configFile(apiKey);
+ if (!file.exists()) {
+ return null;
+ }
+ try {
+ return mapper.readValue(file, GateConfigDTO.class);
+ } catch (IOException e) {
+ log.error("[GateConfig] 读取配置失败, apiKey={}", maskKey(apiKey), e);
+ return null;
+ }
+ }
+
+ /**
+ * 从 DTO 构建 GateConfig(补齐 apiSecret)。
+ */
+ public GateConfig buildFromDTO(GateConfigDTO dto, String apiKey) {
+ return GateConfig.builder()
+ .apiKey(apiKey)
+ .apiSecret(GateWebSocketClientManager.DEFAULT_API_SECRET)
+ // 不可调参数 — 参考 GateWebSocketClientManager 顶部常量
+ .contract(GateWebSocketClientManager.DEFAULT_CONTRACT)
+ .leverage(GateWebSocketClientManager.DEFAULT_LEVERAGE)
+ .marginMode(GateWebSocketClientManager.DEFAULT_MARGIN_MODE)
+ .positionMode(GateWebSocketClientManager.DEFAULT_POSITION_MODE)
+ .restartGridSpan(GateWebSocketClientManager.DEFAULT_RESTART_GRID_SPAN)
+ .priceScale(GateWebSocketClientManager.DEFAULT_PRICE_SCALE)
+ .contractMultiplier(new BigDecimal(GateWebSocketClientManager.DEFAULT_CONTRACT_MULTIPLIER))
+ .unrealizedPnlPriceMode(GateConfig.PnLPriceMode.valueOf(GateWebSocketClientManager.DEFAULT_PNL_MODE))
+ .isProduction(true)
+ .reopenMaxRetries(GateWebSocketClientManager.DEFAULT_REOPEN_MAX_RETRIES)
+ // 可调参数 — 从 DTO 读取
+ .gridRate(nvl(dto.getGridRate(), new BigDecimal("0.005")))
+ .expectedProfit(nvl(dto.getExpectedProfit(), new BigDecimal("0.15")))
+ .maxLoss(nvl(dto.getMaxLoss(), new BigDecimal("1.5")))
+ .baseQuantity(nvl(dto.getBaseQuantity(), "2"))
+ .quantity(nvl(dto.getQuantity(), "2"))
+ .maxPositionSize(dto.getMaxPositionSize())
+ .stopLossCount(dto.getStopLossCount())
+ .build();
+ }
+
+ private File configFile(String apiKey) {
+ return new File(CONFIG_DIR, CONFIG_PREFIX + md5(apiKey) + JSON_SUFFIX);
+ }
+
+ private static String md5(String input) {
+ try {
+ MessageDigest md = MessageDigest.getInstance("MD5");
+ byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder();
+ for (byte b : digest) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (NoSuchAlgorithmException e) {
+ return Integer.toHexString(input.hashCode());
+ }
+ }
+
+ private static String maskKey(String key) {
+ if (key == null || key.length() <= 8) return "***";
+ return key.substring(0, 4) + "****" + key.substring(key.length() - 4);
+ }
+
+ private static <T> T nvl(T value, T fallback) {
+ return value != null ? value : fallback;
+ }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/gateApi/GateLogBuffer.java b/src/main/java/com/xcong/excoin/modules/gateApi/GateLogBuffer.java
new file mode 100644
index 0000000..10e14db
--- /dev/null
+++ b/src/main/java/com/xcong/excoin/modules/gateApi/GateLogBuffer.java
@@ -0,0 +1,86 @@
+package com.xcong.excoin.modules.gateApi;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.io.IOException;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * Gate 策略日志环形缓冲区,支持 SSE 推送到前端。
+ * 最多保留 500 条日志,新客户端连接时先推送历史再持续推新日志。
+ */
+@Slf4j
+@Service
+public class GateLogBuffer {
+
+ private static final int MAX_ENTRIES = 500;
+ private static final DateTimeFormatter TF = DateTimeFormatter.ofPattern("HH:mm:ss");
+
+ private final List<String> entries = Collections.synchronizedList(new ArrayList<>(MAX_ENTRIES));
+ private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();
+
+ /**
+ * 推送一条日志到所有 SSE 客户端。
+ */
+ public void push(String level, String message) {
+ String line = String.format("[%s] %-5s %s", TF.format(LocalTime.now()), level, message);
+
+ synchronized (entries) {
+ if (entries.size() >= MAX_ENTRIES) {
+ entries.remove(0);
+ }
+ entries.add(line);
+ }
+
+ // 推送给所有 SSE 客户端
+ for (SseEmitter emitter : emitters) {
+ try {
+ emitter.send(SseEmitter.event().name("log").data(line));
+ } catch (IOException e) {
+ emitters.remove(emitter);
+ }
+ }
+ }
+
+ /** info 级别日志 */
+ public void info(String message) { push("INFO", message); }
+ /** warn 级别日志 */
+ public void warn(String message) { push("WARN", message); }
+ /** error 级别日志 */
+ public void error(String message) { push("ERROR", message); }
+
+ /**
+ * 创建 SSE 连接,先推送历史再持续推送新日志。
+ */
+ public SseEmitter subscribe() {
+ SseEmitter emitter = new SseEmitter(0L); // 无超时
+ emitters.add(emitter);
+
+ // 推送历史
+ List<String> history;
+ synchronized (entries) {
+ history = new ArrayList<>(entries);
+ }
+ for (String line : history) {
+ try {
+ emitter.send(SseEmitter.event().name("log").data(line));
+ } catch (IOException e) {
+ emitters.remove(emitter);
+ return emitter;
+ }
+ }
+
+ emitter.onCompletion(() -> emitters.remove(emitter));
+ emitter.onTimeout(() -> emitters.remove(emitter));
+ emitter.onError(e -> emitters.remove(emitter));
+
+ return emitter;
+ }
+}
diff --git a/src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientManager.java b/src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientManager.java
index 8313b3b..f68a09e 100644
--- a/src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientManager.java
+++ b/src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientManager.java
@@ -5,6 +5,7 @@
import com.xcong.excoin.modules.gateApi.wsHandler.handler.PositionClosesChannelHandler;
import com.xcong.excoin.modules.gateApi.wsHandler.handler.PositionsChannelHandler;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
@@ -44,6 +45,27 @@
@Component
public class GateWebSocketClientManager {
+ // ==================== 修改配置只需改这里 ===================
+ static final String DEFAULT_API_KEY = "08d6108fc29378fe64d6f0e89745b3fb";
+ static final String DEFAULT_API_SECRET = "4d16e40117477a2344f6fd5872c5b075dc9664a599e65422d3e6791ff64379f1";
+ // 不可调参数
+ static final String DEFAULT_CONTRACT = "ETH_USDT";
+ static final String DEFAULT_LEVERAGE = "100";
+ static final String DEFAULT_MARGIN_MODE = "CROSS";
+ static final String DEFAULT_POSITION_MODE = "dual";
+ static final int DEFAULT_RESTART_GRID_SPAN = 2;
+ static final int DEFAULT_PRICE_SCALE = 2;
+ static final String DEFAULT_CONTRACT_MULTIPLIER = "0.01";
+ static final String DEFAULT_PNL_MODE = "LAST_PRICE";
+ static final int DEFAULT_REOPEN_MAX_RETRIES = 3;
+ // ==========================================================
+
+ @Autowired
+ private GateConfigPersistenceService persistenceService;
+
+ @Autowired
+ private GateLogBuffer logBuffer;
+
/** WebSocket 连接管理器 */
private GateKlineWebSocketClient wsClient;
/** 网格交易策略服务 */
@@ -53,53 +75,35 @@
@PostConstruct
public void init() {
- log.info("[管理器] 开始初始化...");
+ logBuffer.info("[管理器] 加载配置,策略待手动启动...");
try {
- //实盘
- config = GateConfig.builder()
- .apiKey("08d6108fc29378fe64d6f0e89745b3fb")
- .apiSecret("4d16e40117477a2344f6fd5872c5b075dc9664a599e65422d3e6791ff64379f1")
- .contract("ETH_USDT")
- .leverage("100")
- .marginMode("CROSS")
- .positionMode("dual")
- .gridRate(new BigDecimal("0.0025"))
- .expectedProfit(new BigDecimal("2.5"))
- .maxLoss(new BigDecimal("1.5"))
- .baseQuantity("1")
- .quantity("1")
- .restartGridSpan(10)
- .stopLossCount(2)
- .maxPositionSize(2)
- .priceScale(2)
- .contractMultiplier(new BigDecimal("0.01"))
- .unrealizedPnlPriceMode(GateConfig.PnLPriceMode.LAST_PRICE)
- .isProduction(true)
- .reopenMaxRetries(3)
- .build();
+ // 优先读取持久化配置,不存在则用默认值创建文件
+ GateConfigDTO saved = persistenceService.load(DEFAULT_API_KEY);
+ if (saved != null) {
+ config = persistenceService.buildFromDTO(saved, DEFAULT_API_KEY);
+ logBuffer.info("[管理器] 已加载持久化配置, apiKey=" + mask(DEFAULT_API_KEY));
+ } else {
+ // 构建默认配置 DTO(只有 7 项可调参数)
+ GateConfigDTO defaults = GateConfigDTO.builder()
+ .apiKey(DEFAULT_API_KEY)
+ .gridRate(new BigDecimal("0.005"))
+ .expectedProfit(new BigDecimal("0.15"))
+ .maxLoss(new BigDecimal("1.5"))
+ .baseQuantity("2")
+ .quantity("2")
+ .maxPositionSize(4)
+ .stopLossCount(0)
+ .build();
+ // 确保配置文件存在
+ persistenceService.ensureExists(DEFAULT_API_KEY, defaults);
+ config = persistenceService.buildFromDTO(defaults, DEFAULT_API_KEY);
+ logBuffer.info("[管理器] 已创建默认配置文件");
+ }
- // 1. 初始化交易服务:查用户ID → 切持仓模式 → 清条件单 → 平已有仓位 → 设杠杆
- gridTradeService = new GateGridTradeService(config);
- gridTradeService.init();
-
- // 2. 创建 WS 客户端并注册频道处理器
- wsClient = new GateKlineWebSocketClient(config.getWsUrl());
- wsClient.addChannelHandler(new CandlestickChannelHandler(config.getContract(), gridTradeService));
- wsClient.addChannelHandler(new PositionsChannelHandler(
- config.getApiKey(), config.getApiSecret(), config.getContract(), gridTradeService));
- wsClient.addChannelHandler(new PositionClosesChannelHandler(
- config.getApiKey(), config.getApiSecret(), config.getContract(), gridTradeService));
- wsClient.addChannelHandler(new AutoOrdersChannelHandler(
- config.getApiKey(), config.getApiSecret(), config.getContract(), gridTradeService));
- gridTradeService.setWsClient(wsClient);
- wsClient.init();
- log.info("[管理器] WS已连接, 已注册 4 个频道处理器");
-
- // 3. 激活策略,等待首根 K 线触发基底双开
- gridTradeService.startGrid();
+ logBuffer.info("[管理器] 配置就绪,请通过控制面板点击「重启策略」启动");
} catch (Exception e) {
- log.error("[管理器] 初始化失败", e);
+ logBuffer.error("[管理器] 配置加载失败: " + e.getMessage());
}
}
@@ -126,4 +130,60 @@
* @return 网格交易策略服务实例
*/
public GateGridTradeService getGridTradeService() { return gridTradeService; }
+ /**
+ * @return 当前生效的配置
+ */
+ public GateConfig getConfig() { return config; }
+
+ /**
+ * 使用新配置重启策略:停止旧策略 → 重建 WS → 重新 init + startGrid。
+ */
+ public void restartWithConfig(GateConfig newConfig) {
+ logBuffer.info("[管理器] 开始策略重启...");
+ // 1. 停止旧策略 + 断开旧 WS
+ if (gridTradeService != null) {
+ gridTradeService.stopGrid();
+ }
+ if (wsClient != null) {
+ wsClient.destroy();
+ }
+ // 2. 使用新配置重建
+ this.config = newConfig;
+ this.gridTradeService = new GateGridTradeService(config);
+ gridTradeService.init();
+ // 3. 重建 WS 客户端并重新注册 Handler
+ this.wsClient = new GateKlineWebSocketClient(config.getWsUrl());
+ wsClient.addChannelHandler(new CandlestickChannelHandler(config.getContract(), gridTradeService));
+ wsClient.addChannelHandler(new PositionsChannelHandler(
+ config.getApiKey(), config.getApiSecret(), config.getContract(), gridTradeService));
+ wsClient.addChannelHandler(new PositionClosesChannelHandler(
+ config.getApiKey(), config.getApiSecret(), config.getContract(), gridTradeService));
+ wsClient.addChannelHandler(new AutoOrdersChannelHandler(
+ config.getApiKey(), config.getApiSecret(), config.getContract(), gridTradeService));
+ gridTradeService.setWsClient(wsClient);
+ wsClient.init();
+ logBuffer.info("[管理器] WS已重连, Handler 已重新绑定");
+ // 4. 启动策略
+ gridTradeService.startGrid();
+ logBuffer.info("[管理器] 策略已使用新配置重启完成");
+ }
+
+ /**
+ * 停止策略:取消所有条件单 → 停止网格 → 断开 WS。
+ */
+ public void stopStrategy() {
+ logBuffer.info("[管理器] 停止策略...");
+ if (gridTradeService != null) {
+ gridTradeService.stopGrid();
+ }
+ if (wsClient != null) {
+ wsClient.destroy();
+ }
+ logBuffer.info("[管理器] 策略已停止");
+ }
+
+ private static String mask(String key) {
+ if (key == null || key.length() <= 8) return "***";
+ return key.substring(0, 4) + "****" + key.substring(key.length() - 4);
+ }
}
diff --git a/src/main/resources/static/gate-config.html b/src/main/resources/static/gate-config.html
new file mode 100644
index 0000000..3313236
--- /dev/null
+++ b/src/main/resources/static/gate-config.html
@@ -0,0 +1,263 @@
+<!DOCTYPE html>
+<html lang="zh">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Gate 策略控制面板</title>
+ <style>
+ :root {
+ --bg: #0f1117; --card: #1a1d27; --border: #2a2d3a;
+ --text: #e1e4e8; --dim: #8b949e; --accent: #58a6ff;
+ --danger: #f85149; --green: #3fb950; --warn: #d29922;
+ --input-bg: #161b22;
+ }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: var(--bg); color: var(--text); min-height: 100vh; padding: 20px;
+ }
+ .container { max-width: 960px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
+ .card {
+ background: var(--card); border: 1px solid var(--border);
+ border-radius: 8px; padding: 20px;
+ }
+ .card-header {
+ display: flex; justify-content: space-between; align-items: center;
+ margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--border);
+ }
+ h2 { font-size: 18px; display: flex; align-items: center; gap: 10px; }
+ .status-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
+ .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; }
+ .form-group { display: flex; flex-direction: column; gap: 3px; }
+ .form-group.full { grid-column: span 2; }
+ .form-group label { font-size: 11px; color: var(--dim); font-weight: 500; }
+ .form-group input, .form-group select {
+ background: var(--input-bg); border: 1px solid var(--border);
+ border-radius: 5px; color: var(--text); padding: 7px 10px;
+ font-size: 13px; outline: none;
+ }
+ .form-group input:focus, .form-group select:focus { border-color: var(--accent); }
+ .btn {
+ padding: 7px 16px; border-radius: 5px; border: none;
+ font-size: 12px; cursor: pointer; font-weight: 500;
+ }
+ .btn:hover { opacity: .85; }
+ .btn-primary { background: var(--accent); color: #fff; }
+ .btn-success { background: var(--green); color: #fff; }
+ .btn-danger { background: var(--danger); color: #fff; }
+ .btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
+ .btn-group { display: flex; gap: 6px; flex-wrap: wrap; }
+ .api-row { display: flex; gap: 10px; align-items: flex-end; }
+ .api-row .form-group { flex: 1; }
+
+ /* 日志窗口 */
+ .log-panel {
+ height: 280px; overflow-y: auto; background: var(--bg);
+ border: 1px solid var(--border); border-radius: 6px; padding: 10px;
+ font-family: "SF Mono", "Consolas", monospace; font-size: 12px;
+ line-height: 1.6; white-space: pre-wrap; word-break: break-all;
+ }
+ .log-panel .INFO { color: var(--text); }
+ .log-panel .WARN { color: var(--warn); }
+ .log-panel .ERROR { color: var(--danger); }
+ .log-panel .empty { color: var(--dim); text-align: center; padding-top: 60px; }
+
+ .toast {
+ position: fixed; top: 20px; right: 20px; padding: 10px 18px;
+ border-radius: 6px; font-size: 13px; z-index: 999;
+ animation: slideIn .3s ease;
+ }
+ .toast.success { background: #1b3d2b; border:1px solid var(--green); color:var(--green); }
+ .toast.error { background: #3d1b1b; border:1px solid var(--danger); color:var(--danger); }
+ @keyframes slideIn { from{transform:translateX(100px);opacity:0} to{transform:translateX(0);opacity:1} }
+
+ .section-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; color: var(--dim); }
+ .hidden { display: none !important; }
+ </style>
+</head>
+<body>
+<div class="container">
+ <!-- Step 1: apiKey 输入 -->
+ <div class="card" id="step1">
+ <div class="card-header">
+ <h2>🔑 输入 Gate API Key</h2>
+ </div>
+ <div class="api-row">
+ <div class="form-group">
+ <label>API Key</label>
+ <input id="inputApiKey" type="password" placeholder="粘贴你的 Gate API Key">
+ </div>
+ <button class="btn btn-primary" onclick="loadByApiKey()" style="margin-bottom:1px">加载配置</button>
+ </div>
+ </div>
+
+ <!-- Step 2: 配置面板(初始隐藏) -->
+ <div class="card hidden" id="step2">
+ <div class="card-header">
+ <h2>
+ <span class="status-dot" id="statusDot" style="background:var(--dim)"></span>
+ Gate 策略配置
+ <span style="font-size:11px;color:var(--dim);font-weight:400" id="currentApiKey"></span>
+ </h2>
+ <div class="btn-group">
+ <button class="btn btn-outline" onclick="refreshStatus()">刷新状态</button>
+ <button class="btn btn-success" onclick="saveConfig()">保存配置</button>
+ <button class="btn btn-primary" onclick="startStrategy()">启动策略</button>
+ <button class="btn btn-danger" onclick="stopStrategy()">停止策略</button>
+ </div>
+ </div>
+ <form id="configForm" class="form-grid">
+ <div class="form-group"><label>网格间距 (gridRate)</label><input id="gridRate" placeholder="0.005"></div>
+ <div class="form-group"><label>预期收益 USDT</label><input id="expectedProfit" placeholder="0.15"></div>
+ <div class="form-group"><label>最大亏损 USDT</label><input id="maxLoss" placeholder="1.5"></div>
+ <div class="form-group"><label>基底开仓张数</label><input id="baseQuantity" placeholder="2"></div>
+ <div class="form-group"><label>每次下单张数</label><input id="quantity" placeholder="2"></div>
+ <div class="form-group"><label>最大持仓张数</label><input id="maxPositionSize" placeholder="4"></div>
+ <div class="form-group"><label>止损阶梯次数</label><input id="stopLossCount" placeholder="0"></div>
+ </form>
+ </div>
+
+ <!-- 日志面板 -->
+ <div class="card">
+ <div class="card-header">
+ <h2>📋 实时日志</h2>
+ <button class="btn btn-outline btn-sm" onclick="clearLogs()">清屏</button>
+ </div>
+ <div class="log-panel" id="logPanel">
+ <div class="empty">等待日志...</div>
+ </div>
+ </div>
+</div>
+<div id="toast"></div>
+
+<script>
+const API = '/api/gate';
+let currentApiKey = '';
+let logLines = [];
+const logPanel = document.getElementById('logPanel');
+
+function toast(msg, type) {
+ const t = document.getElementById('toast');
+ t.innerHTML = msg; t.className = 'toast ' + type;
+ setTimeout(() => t.innerHTML = '', 3000);
+}
+
+function maskKey(k) { return k && k.length > 8 ? k.slice(0, 4) + '****' + k.slice(-4) : '***'; }
+
+// ========== 日志 ==========
+function appendLog(line) {
+ logLines.push(line);
+ if (logLines.length > 500) logLines.shift();
+ if (logPanel.querySelector('.empty')) logPanel.innerHTML = '';
+ const div = document.createElement('div');
+ const cls = line.includes('WARN') ? 'WARN' : line.includes('ERROR') ? 'ERROR' : 'INFO';
+ div.className = cls;
+ div.textContent = line;
+ logPanel.appendChild(div);
+ logPanel.scrollTop = logPanel.scrollHeight;
+}
+function clearLogs() {
+ logLines = [];
+ logPanel.innerHTML = '<div class="empty">已清屏</div>';
+}
+
+// SSE 连接(浏览器自动重连,无需手动 reconnect)
+(function initSSE() {
+ const es = new EventSource(API + '/logs');
+ es.addEventListener('log', e => appendLog(e.data));
+ es.onerror = () => { appendLog('[SYSTEM] SSE 连接断开,浏览器将自动重连...'); };
+})();
+
+// ========== API ==========
+async function fetchApi(url, opts) {
+ const res = await fetch(url, opts);
+ const data = await res.json();
+ if (data.code !== 0 && data.code !== 200) throw new Error(data.msg || '请求失败');
+ return data.data !== undefined ? data.data : data.msg;
+}
+
+// Step 1: 输入 apiKey 加载配置
+async function loadByApiKey() {
+ const key = document.getElementById('inputApiKey').value.trim();
+ if (!key) return toast('请输入 API Key', 'error');
+ try {
+ const d = await fetchApi(API + '/config/load?apiKey=' + encodeURIComponent(key));
+ fillForm(d);
+ toast('配置已加载', 'success');
+ } catch(e) {
+ // 文件不存在 → 用默认值填充,用户可编辑后保存
+ fillForm({ gridRate:0.005, expectedProfit:0.15, maxLoss:1.5,
+ baseQuantity:'2', quantity:'2', maxPositionSize:4, stopLossCount:0 });
+ toast('未找到配置,已加载默认值,编辑后请保存', 'success');
+ }
+ currentApiKey = key;
+ document.getElementById('currentApiKey').textContent = 'apiKey: ' + maskKey(key);
+ document.getElementById('step2').classList.remove('hidden');
+ refreshStatus();
+}
+
+function fillForm(d) {
+ document.getElementById('gridRate').value = d.gridRate || '';
+ document.getElementById('expectedProfit').value = d.expectedProfit || '';
+ document.getElementById('maxLoss').value = d.maxLoss || '';
+ document.getElementById('baseQuantity').value = d.baseQuantity || '';
+ document.getElementById('quantity').value = d.quantity || '';
+ document.getElementById('maxPositionSize').value = d.maxPositionSize ?? '';
+ document.getElementById('stopLossCount').value = d.stopLossCount ?? '';
+}
+
+function collectForm() {
+ return {
+ apiKey: currentApiKey,
+ gridRate: parseFloat($('#gridRate').value) || 0,
+ expectedProfit: parseFloat($('#expectedProfit').value) || 0,
+ maxLoss: parseFloat($('#maxLoss').value) || 0,
+ baseQuantity: $('#baseQuantity').value,
+ quantity: $('#quantity').value,
+ maxPositionSize: parseInt($('#maxPositionSize').value) || 0,
+ stopLossCount: parseInt($('#stopLossCount').value) || 0
+ };
+}
+
+function $(id) { return document.getElementById(id); }
+
+async function saveConfig() {
+ if (!currentApiKey) return toast('请先输入 API Key', 'error');
+ try {
+ const body = collectForm();
+ const msg = await fetchApi(API + '/config', {
+ method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
+ });
+ toast(msg || '保存成功', 'success');
+ } catch(e) { toast(e.message, 'error'); }
+}
+
+async function startStrategy() {
+ if (!currentApiKey) return toast('请先输入 API Key', 'error');
+ if (!confirm('确定要启动策略吗?')) return;
+ try {
+ const msg = await fetchApi(API + '/restart?apiKey=' + encodeURIComponent(currentApiKey), { method: 'POST' });
+ toast(msg, 'success');
+ refreshStatus();
+ } catch(e) { toast(e.message, 'error'); }
+}
+
+async function stopStrategy() {
+ if (!confirm('确定要停止策略吗?')) return;
+ try {
+ const msg = await fetchApi(API + '/stop', { method: 'POST' });
+ toast(msg, 'success');
+ refreshStatus();
+ } catch(e) { toast(e.message, 'error'); }
+}
+
+async function refreshStatus() {
+ try {
+ const s = await fetchApi(API + '/status');
+ const dot = document.getElementById('statusDot');
+ dot.style.background = s === 'ACTIVE' ? 'var(--green)' : 'var(--danger)';
+ } catch(e) {}
+}
+</script>
+</body>
+</html>
--
Gitblit v1.9.1