From ac5fe45471a56ee028d5bed42b5ff6fd1996abb8 Mon Sep 17 00:00:00 2001
From: Administrator <15274802129@163.com>
Date: Wed, 08 Jul 2026 11:51:53 +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 533fe8b..89e94e5 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 = "dbf5cc322a0f3d6f308e143c494fb0b4";
+    static final String DEFAULT_API_SECRET = "83cb291e21e62148ec99bc9adef493ee9f7fc195f2b9dc929466d70350ec58c3";
+    // 不可调参数
+    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("dbf5cc322a0f3d6f308e143c494fb0b4")
-                    .apiSecret("83cb291e21e62148ec99bc9adef493ee9f7fc195f2b9dc929466d70350ec58c3")
-                    .contract("ETH_USDT")
-                    .leverage("100")
-                    .marginMode("CROSS")
-                    .positionMode("dual")
-                    .gridRate(new BigDecimal("0.0025"))
-                    .expectedProfit(new BigDecimal("5"))
-                    .maxLoss(new BigDecimal("15"))
-                    .baseQuantity("33")
-                    .quantity("33")
-                    .maxPositionSize(66)
-                    .stopLossCount(2)
-                    .restartGridSpan(4)
-                    .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