Administrator
13 hours ago 507cb9001b46665af0c3e41673c23fecd1cf10c2
feat(gate): 添加 Gate 策略控制面板和配置管理功能

- 新增 gate-config.html 页面,提供完整的策略配置界面
- 实现 GateConfigController 提供 REST API 接口支持配置加载、保存和策略控制
- 添加 GateConfigDTO 数据传输对象用于前后端数据交互
- 实现 GateConfigPersistenceService 配置持久化服务,按 apiKey 分文件存储
- 集成 SSE 实时日志流功能,支持前端实时查看策略日志
- 更新 GateWebSocketClientManager 支持动态配置加载和策略重启功能
- 在 WebSecurityConfig 中开放 Gate 相关接口访问权限
- 添加 GateLogBuffer 日志环形缓冲区支持实时日志推送
5 files added
2 files modified
845 ■■■■■ changed files
src/main/java/com/xcong/excoin/configurations/security/WebSecurityConfig.java 2 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/gateApi/GateConfigController.java 133 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/gateApi/GateConfigDTO.java 80 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/gateApi/GateConfigPersistenceService.java 135 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/gateApi/GateLogBuffer.java 86 ●●●●● patch | view | raw | blame | history
src/main/java/com/xcong/excoin/modules/gateApi/GateWebSocketClientManager.java 146 ●●●● patch | view | raw | blame | history
src/main/resources/static/gate-config.html 263 ●●●●● patch | view | raw | blame | history
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());
    }
src/main/java/com/xcong/excoin/modules/gateApi/GateConfigController.java
New file
@@ -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);
    }
}
src/main/java/com/xcong/excoin/modules/gateApi/GateConfigDTO.java
New file
@@ -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();
    }
}
src/main/java/com/xcong/excoin/modules/gateApi/GateConfigPersistenceService.java
New file
@@ -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;
    }
}
src/main/java/com/xcong/excoin/modules/gateApi/GateLogBuffer.java
New file
@@ -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;
    }
}
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 = "8a995c9662bbdfb0b2577f5f56cf4210";
    static final String DEFAULT_API_SECRET = "4aaf5e7c97cf3bd14450c6703d4dc2af8647dee4c0a72272fa04280e83545d96";
    // 不可调参数
    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("8a995c9662bbdfb0b2577f5f56cf4210")
                    .apiSecret("4aaf5e7c97cf3bd14450c6703d4dc2af8647dee4c0a72272fa04280e83545d96")
                    .contract("ETH_USDT")
                    .leverage("100")
                    .marginMode("CROSS")
                    .positionMode("dual")
                    .gridRate(new BigDecimal("0.0025"))
                    .expectedProfit(new BigDecimal("4.5"))
                    .maxLoss(new BigDecimal("15"))
                    .baseQuantity("35")
                    .quantity("35")
                    .maxPositionSize(70)
                    .stopLossCount(2)
                    .restartGridSpan(8)
                    .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);
    }
}
src/main/resources/static/gate-config.html
New file
@@ -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>