Files
volcengine-server-manager/docs/superpowers/plans/2026-04-04-volcengine-server-manager-plan.md

31 KiB
Raw Permalink Blame History

Volcengine Server Manager 实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 构建 Windows 桌面应用,管理火山引擎 ECS 服务器,支持查看实例状态和重启实例。

Architecture: 使用 egui + eframe 构建 GUI通过 HTTP 直接调用火山引擎 APIHMAC-SHA256 签名),异步 API 调用通过 tokio spawn 在后台线程执行,结果通过 channel 返回 UI 线程。

Tech Stack: Rust, egui 0.29, eframe (glow), reqwest, tokio, serde, serde_json, sha2, hmac, hex, base64, chrono


文件结构

文件 职责 状态
Cargo.toml 项目配置、依赖声明 新建
src/main.rs 入口、字体加载、启动 eframe 新建
src/app.rs egui UI 主逻辑、状态管理、事件处理 新建
src/config.rs 配置结构体、JSON 读写 新建
src/api.rs 火山引擎 API 调用HTTP + HMAC-SHA256 签名) 新建
src/types.rs 数据类型定义InstanceInfo、InstanceStatus 等) 新建

决策:不使用 volcengine-rust-sdk

原因:

  1. SDK 依赖 protobuf在 MinGW 环境下编译风险高
  2. SDK 功能不完整(仅 11 commits0 stars可能不包含 RebootInstance 等接口
  3. 火山引擎 API 签名机制简单HMAC-SHA256直接 HTTP 调用更可控
  4. 减少依赖体积,单 exe 更小

Task 1: 项目初始化

Files:

  • Create: Cargo.toml

  • Create: src/main.rs (最小入口)

  • Create: src/types.rs

  • Create: src/config.rs

  • Create: src/api.rs

  • Create: src/app.rs

  • Step 1: 创建 Cargo.toml

[package]
name = "volcengine-server-manager"
version = "0.1.0"
edition = "2021"

[dependencies]
eframe = { version = "0.29", default-features = false, features = ["glow"] }
egui = "0.29"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
hmac = "0.12"
hex = "0.4"
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
dirs = "5"
urlencoding = "2.1"
  • Step 2: 创建 src/types.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq)]
pub enum InstanceStatus {
    Running,
    Stopped,
    Starting,
    Stopping,
    Rebooting,
    Error,
    Unknown,
}

impl InstanceStatus {
    pub fn from_str(s: &str) -> Self {
        match s {
            "RUNNING" => InstanceStatus::Running,
            "STOPPED" => InstanceStatus::Stopped,
            "STARTING" => InstanceStatus::Starting,
            "STOPPING" => InstanceStatus::Stopping,
            "REBOOTING" => InstanceStatus::Rebooting,
            "ERROR" => InstanceStatus::Error,
            _ => InstanceStatus::Unknown,
        }
    }

    pub fn label(&self) -> &str {
        match self {
            InstanceStatus::Running => "运行中",
            InstanceStatus::Stopped => "已停止",
            InstanceStatus::Starting => "启动中",
            InstanceStatus::Stopping => "停止中",
            InstanceStatus::Rebooting => "重启中",
            InstanceStatus::Error => "错误",
            InstanceStatus::Unknown => "未知",
        }
    }
}

#[derive(Debug, Clone)]
pub struct InstanceInfo {
    pub instance_id: String,
    pub instance_name: String,
    pub status: InstanceStatus,
    pub private_ip: String,
    pub public_ip: String,
    pub zone_id: String,
    pub region_id: String,
    pub instance_type: String,
    pub creation_time: String,
}

#[derive(Debug, Clone)]
pub enum AppState {
    Loading,
    Ready,
    Error(String),
}

#[derive(Debug, Clone)]
pub enum ApiMessage {
    InstancesLoaded(Vec<InstanceInfo>),
    RebootSuccess(String),
    Error(String),
}
  • Step 3: 创建 src/config.rs
use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::PathBuf;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
    pub access_key_id: String,
    pub secret_access_key: String,
    #[serde(default = "default_endpoint")]
    pub endpoint: String,
}

fn default_endpoint() -> String {
    "ecs.volcengineapi.com".to_string()
}

impl AppConfig {
    pub fn is_configured(&self) -> bool {
        !self.access_key_id.is_empty() && !self.secret_access_key.is_empty()
    }
}

pub fn config_dir() -> PathBuf {
    dirs::config_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join("volcengine-server-manager")
}

pub fn config_path() -> PathBuf {
    config_dir().join("config.json")
}

pub fn load_config() -> Result<AppConfig, io::Error> {
    let path = config_path();
    if !path.exists() {
        return Ok(AppConfig {
            access_key_id: String::new(),
            secret_access_key: String::new(),
            endpoint: default_endpoint(),
        });
    }
    let content = fs::read_to_string(&path)?;
    let config: AppConfig = serde_json::from_str(&content)?;
    Ok(config)
}

pub fn save_config(config: &AppConfig) -> Result<(), io::Error> {
    let dir = config_dir();
    fs::create_dir_all(&dir)?;
    let content = serde_json::to_string_pretty(config)?;
    fs::write(config_path(), content)?;
    Ok(())
}
  • Step 4: 创建 src/api.rs (骨架版本)
use crate::types::InstanceInfo;

pub struct VolcClient;

impl VolcClient {
    pub fn new(_access_key_id: String, _secret_access_key: String, _endpoint: String) -> Self {
        Self
    }

    pub async fn list_instances(&self) -> Result<Vec<InstanceInfo>, String> {
        Err("API 未实现".to_string())
    }

    pub async fn reboot_instance(&self, _instance_id: &str) -> Result<(), String> {
        Err("API 未实现".to_string())
    }
}

注意: Task 3 会替换此文件为完整的 API 实现。Task 1 的骨架版本仅用于验证编译通过。

  • Step 5: 创建 src/app.rs
use crate::api::VolcClient;
use crate::config::{load_config, save_config, AppConfig};
use crate::types::{ApiMessage, AppState, InstanceInfo};
use eframe::egui;
use std::sync::mpsc;
use std::thread;

pub struct VolcManagerApp {
    config: AppConfig,
    instances: Vec<InstanceInfo>,
    app_state: AppState,
    show_config_dialog: bool,
    show_reboot_confirm: Option<InstanceInfo>,
    rebooting_instance: Option<String>,
    last_refresh: Option<String>,
    rx: Option<mpsc::Receiver<ApiMessage>>,
    config_ak: String,
    config_sk: String,
    config_endpoint: String,
    status_message: Option<(String, std::time::Instant)>,
}

impl VolcManagerApp {
    pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
        setup_fonts(&cc.egui_ctx);
        let config = load_config().unwrap_or_else(|_| AppConfig {
            access_key_id: String::new(),
            secret_access_key: String::new(),
            endpoint: "ecs.volcengineapi.com".to_string(),
        });
        let show_config_dialog = !config.is_configured();
        Self {
            config,
            instances: Vec::new(),
            app_state: AppState::Ready,
            show_config_dialog,
            show_reboot_confirm: None,
            rebooting_instance: None,
            last_refresh: None,
            rx: None,
            config_ak: String::new(),
            config_sk: String::new(),
            config_endpoint: "ecs.volcengineapi.com".to_string(),
            status_message: None,
        }
    }

    fn refresh_instances(&mut self) {
        if !self.config.is_configured() {
            self.app_state = AppState::Error("请先配置 Access Key".to_string());
            return;
        }
        let (tx, rx) = mpsc::channel();
        self.rx = Some(rx);
        self.app_state = AppState::Loading;
        let config = self.config.clone();
        thread::spawn(move || {
            let rt = tokio::runtime::Runtime::new().unwrap();
            let result = rt.block_on(async {
                let client = VolcClient::new(
                    config.access_key_id.clone(),
                    config.secret_access_key.clone(),
                    config.endpoint.clone(),
                );
                match client.list_instances().await {
                    Ok(instances) => ApiMessage::InstancesLoaded(instances),
                    Err(e) => ApiMessage::Error(e),
                }
            });
            let _ = tx.send(result);
        });
    }

    fn process_messages(&mut self) {
        if let Some(rx) = &self.rx {
            if let Ok(msg) = rx.try_recv() {
                match msg {
                    ApiMessage::InstancesLoaded(instances) => {
                        self.instances = instances;
                        self.app_state = AppState::Ready;
                        self.last_refresh = Some(
                            chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
                        );
                    }
                    ApiMessage::RebootSuccess(id) => {
                        self.status_message = Some((
                            format!("实例 {} 重启命令已发送", id),
                            std::time::Instant::now(),
                        ));
                        self.rebooting_instance = None;
                        self.refresh_instances();
                    }
                    ApiMessage::Error(e) => {
                        self.app_state = AppState::Error(e.clone());
                        self.status_message =
                            Some((e, std::time::Instant::now()));
                    }
                }
                self.rx = None;
            }
        }
        if let Some((_, time)) = &self.status_message {
            if time.elapsed().as_secs() > 5 {
                self.status_message = None;
            }
        }
    }

    fn open_config_dialog(&mut self) {
        self.config_ak = self.config.access_key_id.clone();
        self.config_sk = self.config.secret_access_key.clone();
        self.config_endpoint = self.config.endpoint.clone();
        self.show_config_dialog = true;
    }

    fn save_config_dialog(&mut self) {
        self.config.access_key_id = self.config_ak.clone();
        self.config.secret_access_key = self.config_sk.clone();
        self.config.endpoint = self.config_endpoint.clone();
        if let Err(e) = save_config(&self.config) {
            self.app_state = AppState::Error(format!("保存配置失败: {}", e));
        }
        self.show_config_dialog = false;
        if self.config.is_configured() {
            self.refresh_instances();
        }
    }
}

impl eframe::App for VolcManagerApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        self.process_messages();
        egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
            ui.horizontal(|ui| {
                ui.heading("火山引擎服务器管理");
                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                    if ui.button("⚙ 配置").clicked() {
                        self.open_config_dialog();
                    }
                    if ui.button("🔄 刷新").clicked() {
                        self.refresh_instances();
                    }
                });
            });
        });

        egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| {
            ui.horizontal(|ui| {
                ui.label(format!("实例数: {}", self.instances.len()));
                if let Some(time) = &self.last_refresh {
                    ui.label(format!("最后刷新: {}", time));
                }
                if let Some((msg, _)) = &self.status_message {
                    ui.label(format!("| {}", msg));
                }
            });
        });

        egui::CentralPanel::default().show(ctx, |ui| {
            match &self.app_state {
                AppState::Loading => {
                    ui.centered_and_justified(|ui| {
                        ui.spinner();
                        ui.label("  加载中...");
                    });
                }
                AppState::Error(e) if self.instances.is_empty() => {
                    ui.centered_and_justified(|ui| {
                        ui.colored_label(egui::Color32::RED, e);
                    });
                }
                _ => {
                    ui.horizontal_wrapped(|ui| {
                        for instance in &self.instances {
                            render_instance_card(ui, instance, |id| {
                                self.show_reboot_confirm =
                                    Some(instance.clone());
                            });
                        }
                    });
                }
            }
        });

        if self.show_config_dialog {
            egui::Window::new("配置")
                .collapsible(false)
                .resizable(false)
                .fixed_size([400.0, 250.0])
                .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
                .show(ctx, |ui| {
                    ui.vertical(|ui| {
                        ui.label("Access Key ID:");
                        ui.text_edit_singleline(&mut self.config_ak);
                        ui.add_space(8.0);
                        ui.label("Secret Access Key:");
                        ui.text_edit_singleline(&mut self.config_sk);
                        ui.add_space(8.0);
                        ui.label("Endpoint:");
                        ui.text_edit_singleline(&mut self.config_endpoint);
                        ui.add_space(16.0);
                        ui.horizontal(|ui| {
                            if ui.button("保存").clicked() {
                                self.save_config_dialog();
                            }
                            if ui.button("取消").clicked() {
                                self.show_config_dialog = false;
                            }
                        });
                    });
                });
        }

        if let Some(instance) = &self.show_reboot_confirm {
            egui::Window::new("确认重启")
                .collapsible(false)
                .resizable(false)
                .fixed_size([350.0, 150.0])
                .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
                .show(ctx, |ui| {
                    ui.label(format!(
                        "确定要重启实例 \"{}\" 吗?",
                        instance.instance_name
                    ));
                    ui.label(format!("实例 ID: {}", instance.instance_id));
                    ui.add_space(16.0);
                    ui.horizontal(|ui| {
                        if ui.button("确定").clicked() {
                            let config = self.config.clone();
                            let instance_id = instance.instance_id.clone();
                            let (tx, rx) = mpsc::channel();
                            self.rx = Some(rx);
                            self.rebooting_instance = Some(instance_id.clone());
                            thread::spawn(move || {
                                let rt = tokio::runtime::Runtime::new().unwrap();
                                let result = rt.block_on(async {
                                    let client = VolcClient::new(
                                        config.access_key_id.clone(),
                                        config.secret_access_key.clone(),
                                        config.endpoint.clone(),
                                    );
                                    match client.reboot_instance(&instance_id).await {
                                        Ok(()) => ApiMessage::RebootSuccess(instance_id),
                                        Err(e) => ApiMessage::Error(e),
                                    }
                                });
                                let _ = tx.send(result);
                            });
                            self.show_reboot_confirm = None;
                        }
                        if ui.button("取消").clicked() {
                            self.show_reboot_confirm = None;
                        }
                    });
                });
        }

        ctx.request_repaint();
    }
}

fn render_instance_card(
    ui: &mut egui::Ui,
    instance: &InstanceInfo,
    on_reboot: impl FnOnce(&str),
) {
    let status_color = match instance.status {
        crate::types::InstanceStatus::Running => egui::Color32::GREEN,
        crate::types::InstanceStatus::Stopped => egui::Color32::GRAY,
        crate::types::InstanceStatus::Starting => egui::Color32::BLUE,
        crate::types::InstanceStatus::Stopping => egui::Color32::from_rgb(255, 165, 0),
        crate::types::InstanceStatus::Rebooting => egui::Color32::from_rgb(255, 165, 0),
        crate::types::InstanceStatus::Error => egui::Color32::RED,
        crate::types::InstanceStatus::Unknown => egui::Color32::YELLOW,
    };

    egui::Frame::group(ui.style())
        .fill(egui::Color32::from_additive_luminance(10))
        .inner_margin(12.0)
        .show(ui, |ui| {
            ui.set_max_width(220.0);
            ui.label(egui::RichText::new(&instance.instance_name).strong());
            ui.horizontal(|ui| {
                ui.small("状态:");
                ui.colored_label(status_color, instance.status.label());
            });
            ui.small(format!("IP: {}", instance.public_ip));
            ui.small(format!("区域: {}", instance.region_id));
            ui.small(format!("规格: {}", instance.instance_type));
            ui.add_space(8.0);
            if ui.button("🔄 重启").clicked() {
                on_reboot(&instance.instance_id);
            }
        });
}

fn setup_fonts(ctx: &egui::Context) {
    let mut fonts = egui::FontDefinitions::default();
    // 字体文件在 Task 2 中添加,这里先用默认字体
    // Task 2 完成后,取消下面注释并删除 fallback 逻辑
    // fonts.font_data.insert(
    //     "msyh".to_owned(),
    //     std::sync::Arc::new(egui::FontData::from_static(include_bytes!(
    //         "../fonts/msyh.ttc"
    //     ))),
    // );
    // fonts
    //     .families
    //     .entry(egui::FontFamily::Proportional)
    //     .or_default()
    //     .insert(0, "msyh".to_owned());
    ctx.set_fonts(fonts);
}
  • Step 6: 创建 src/main.rs
#![windows_subsystem = "windows"]

mod api;
mod app;
mod config;
mod types;

use app::VolcManagerApp;

fn main() {
    let native_options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default()
            .with_inner_size([900.0, 600.0])
            .with_min_inner_size([600.0, 400.0])
            .with_title("火山引擎服务器管理"),
        ..Default::default()
    };

    eframe::run_native(
        "火山引擎服务器管理",
        native_options,
        Box::new(|cc| Ok(Box::new(VolcManagerApp::new(cc)))),
    )
    .unwrap();
}
  • Step 7: 创建 .gitignore
/target
Cargo.lock
  • Step 8: 验证编译

运行: cargo check --target x86_64-pc-windows-gnu

预期: 编译通过(可能有 unused warnings

注意: api.rs 中使用了 urlencoding crate需要在 Cargo.toml 中添加:

urlencoding = "2.1"
  • Step 9: 提交
git add .
git commit -m "feat: 初始化项目结构"

Task 2: 添加中文字体文件

Files:

  • Create: fonts/msyh.ttc

  • Step 1: 复制微软雅黑字体文件

从 Windows 系统复制字体文件:

cp /c/Windows/Fonts/msyh.ttc fonts/msyh.ttc

如果 msyh.ttc 是 TTCTrueType Collection格式egui 可能不支持。需要提取其中的 TTF 文件。备选方案:使用 msyh.ttf 或使用开源字体。

检查字体格式:

file fonts/msyh.ttc

如果是 TTC 格式,使用 fonttools 提取:

pip install fonttools
pyftsubset msyh.ttc --output-file=msyh.ttf

或者直接使用 Windows 自带的 simsun.ttc(宋体)或其他可用字体。

备选方案: 如果 TTC 不支持,下载开源中文字体 NotoSansSC-Regular.ttf 放到 fonts/ 目录,并修改 app.rs 中的 setup_fonts 函数引用。

  • Step 2: 更新 app.rs 中的 setup_fonts 函数

src/app.rs 中的 setup_fonts 函数替换为:

fn setup_fonts(ctx: &egui::Context) {
    let mut fonts = egui::FontDefinitions::default();
    fonts.font_data.insert(
        "msyh".to_owned(),
        std::sync::Arc::new(egui::FontData::from_static(include_bytes!(
            "../fonts/msyh.ttc"
        ))),
    );
    fonts
        .families
        .entry(egui::FontFamily::Proportional)
        .or_default()
        .insert(0, "msyh".to_owned());
    fonts
        .families
        .entry(egui::FontFamily::Monospace)
        .or_default()
        .push("msyh".to_owned());
    ctx.set_fonts(fonts);
}
  • Step 3: 提交
git add fonts/ .gitignore
git commit -m "feat: 添加中文字体支持"

Task 3: 完善 API 签名和错误处理

Files:

  • Modify: src/api.rs

  • Step 1: 修正火山引擎签名算法

火山引擎使用 V4 签名算法,不是简单的 HMAC-SHA256。更新 api.rs 中的签名逻辑:

use crate::types::{InstanceInfo, InstanceStatus};
use base64::{engine::general_purpose::STANDARD, Engine};
use chrono::Utc;
use hmac::{Hmac, Mac};
use reqwest::Client;
use sha2::Sha256;
use std::collections::HashMap;

type HmacSha256 = Hmac<Sha256>;

pub struct VolcClient {
    client: Client,
    access_key_id: String,
    secret_access_key: String,
    endpoint: String,
}

impl VolcClient {
    pub fn new(access_key_id: String, secret_access_key: String, endpoint: String) -> Self {
        Self {
            client: Client::builder()
                .danger_accept_invalid_certs(false)
                .build()
                .unwrap_or_default(),
            access_key_id,
            secret_access_key,
            endpoint,
        }
    }

    pub async fn list_instances(&self) -> Result<Vec<InstanceInfo>, String> {
        let regions = self.get_regions().await?;
        let mut all_instances = Vec::new();
        for region in regions {
            match self.list_instances_by_region(&region).await {
                Ok(mut instances) => all_instances.append(&mut instances),
                Err(e) => eprintln!("Failed to list instances in {}: {}", region, e),
            }
        }
        Ok(all_instances)
    }

    async fn get_regions(&self) -> Result<Vec<String>, String> {
        let mut params = HashMap::new();
        params.insert("Action", "DescribeRegions");
        params.insert("Version", "2020-04-01");

        let resp = self.send_request(params).await?;
        let regions = resp["Result"]
            .as_array()
            .map(|arr| {
                arr.iter()
                    .filter_map(|r| r["RegionId"].as_str().map(String::from))
                    .collect()
            })
            .or_else(|| {
                resp["RegionInfos"].as_array().map(|arr| {
                    arr.iter()
                        .filter_map(|r| r["RegionId"].as_str().map(String::from))
                        .collect()
                })
            })
            .unwrap_or_else(|| {
                vec![
                    "cn-beijing".to_string(),
                    "cn-shanghai".to_string(),
                    "cn-guangzhou".to_string(),
                    "cn-chengdu".to_string(),
                    "ap-singapore-1".to_string(),
                ]
            });
        Ok(regions)
    }

    async fn list_instances_by_region(&self, region: &str) -> Result<Vec<InstanceInfo>, String> {
        let mut params = HashMap::new();
        params.insert("Action", "DescribeInstances");
        params.insert("Version", "2020-04-01");
        params.insert("RegionId", region);
        params.insert("MaxResults", "100");

        let resp = self.send_request(params).await?;
        let instances = resp["Result"]["Instances"]
            .as_array()
            .or_else(|| resp["Instances"].as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|inst| {
                        let status_str = inst["Status"].as_str().unwrap_or("UNKNOWN");
                        let private_ip = inst["PrimaryIpAddress"]
                            .as_str()
                            .or_else(|| {
                                inst["NetworkInterfaces"]
                                    .as_array()
                                    .and_then(|nets| {
                                        nets.first()
                                            .and_then(|n| n["PrimaryIpAddress"].as_str())
                                    })
                            })
                            .unwrap_or("-")
                            .to_string();
                        let public_ip = inst["EipAddress"]["IPAddress"]
                            .as_str()
                            .or_else(|| {
                                inst["PublicIpAddresses"]
                                    .as_array()
                                    .and_then(|ips| ips.first().and_then(|ip| ip.as_str()))
                            })
                            .or_else(|| inst["EipAddress"].as_str())
                            .unwrap_or("-")
                            .to_string();

                        Some(InstanceInfo {
                            instance_id: inst["InstanceId"]
                                .as_str()
                                .unwrap_or("")
                                .to_string(),
                            instance_name: inst["InstanceName"]
                                .as_str()
                                .unwrap_or(
                                    inst["InstanceId"].as_str().unwrap_or(""),
                                )
                                .to_string(),
                            status: InstanceStatus::from_str(status_str),
                            private_ip,
                            public_ip,
                            zone_id: inst["ZoneId"]
                                .as_str()
                                .unwrap_or("-")
                                .to_string(),
                            region_id: region.to_string(),
                            instance_type: inst["InstanceType"]
                                .as_str()
                                .unwrap_or("-")
                                .to_string(),
                            creation_time: inst["CreatedAt"]
                                .as_str()
                                .or_else(|| inst["CreationTime"].as_str())
                                .unwrap_or("-")
                                .to_string(),
                        })
                    })
                    .collect()
            })
            .unwrap_or_default();
        Ok(instances)
    }

    pub async fn reboot_instance(&self, instance_id: &str) -> Result<(), String> {
        let mut params = HashMap::new();
        params.insert("Action", "RebootInstance");
        params.insert("Version", "2020-04-01");
        params.insert("InstanceId", instance_id);

        let _resp = self.send_request(params).await?;
        Ok(())
    }

    async fn send_request(
        &self,
        mut params: HashMap<&str, &str>,
    ) -> Result<serde_json::Value, String> {
        params.insert("AccessKeyId", &self.access_key_id);
        params.insert("SignatureVersion", "1.0");
        params.insert("SignatureMethod", "HMAC-SHA256");

        let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
        params.insert("Timestamp", &timestamp);

        let string_to_sign = Self::build_string_to_sign(&params);
        let signature = self.sign(&string_to_sign);
        params.insert("Signature", &signature);

        let url = format!("https://{}/", self.endpoint);
        let resp = self
            .client
            .get(&url)
            .query(&params)
            .send()
            .await
            .map_err(|e| format!("网络请求失败: {}", e))?;

        let status = resp.status();
        let body: serde_json::Value = resp
            .json()
            .await
            .map_err(|e| format!("解析响应失败: {}", e))?;

        if !status.is_success() {
            let msg = body["ResponseMetadata"]["Error"]["Message"]
                .as_str()
                .unwrap_or("未知错误")
                .to_string();
            return Err(msg);
        }

        if let Some(error) = body["ResponseMetadata"]["Error"].as_object() {
            if !error.is_empty() {
                let msg = body["ResponseMetadata"]["Error"]["Message"]
                    .as_str()
                    .unwrap_or("API 错误")
                    .to_string();
                return Err(msg);
            }
        }

        Ok(body)
    }

    fn build_string_to_sign(params: &HashMap<&str, &str>) -> String {
        let mut keys: Vec<&str> = params.keys().copied().collect();
        keys.sort();
        let query: Vec<String> = keys
            .iter()
            .map(|k| {
                format!(
                    "{}={}",
                    url_encode(k),
                    url_encode(params.get(k).unwrap())
                )
            })
            .collect();
        format!("GET\n/\n{}\n", query.join("&"))
    }

    fn sign(&self, string_to_sign: &str) -> String {
        let mut mac = HmacSha256::new_from_slice(self.secret_access_key.as_bytes())
            .expect("HMAC can take key of any size");
        mac.update(string_to_sign.as_bytes());
        let result = mac.finalize();
        STANDARD.encode(result.into_bytes())
    }
}

fn url_encode(s: &str) -> String {
    urlencoding::encode(s).into_owned()
}
  • Step 2: 添加 urlencoding 依赖

Cargo.toml 中添加:

urlencoding = "2.1"
  • Step 3: 验证编译

运行: cargo check --target x86_64-pc-windows-gnu

  • Step 4: 提交
git add src/api.rs Cargo.toml
git commit -m "feat: 完善 API 签名和错误处理"

Task 4: Release 编译配置

Files:

  • Modify: Cargo.toml

  • Step 1: 添加 release profile 配置

Cargo.toml 末尾添加:

[profile.release]
opt-level = 2
strip = true
lto = true
codegen-units = 1
  • Step 2: 执行 release 编译

运行: cargo build --release --target x86_64-pc-windows-gnu

预期: 生成 target/x86_64-pc-windows-gnu/release/volcengine-server-manager.exe

  • Step 3: 验证 exe 无控制台

运行: file target/x86_64-pc-windows-gnu/release/volcengine-server-manager.exe

确认输出包含 windows gui 而非 console

  • Step 4: 提交
git add Cargo.toml
git commit -m "chore: 添加 release 编译优化配置"

Task 5: 首次运行测试

Files:

  • 无修改

  • Step 1: 运行程序

./target/x86_64-pc-windows-gnu/release/volcengine-server-manager.exe
  • Step 2: 验证行为
  1. 首次启动应弹出配置窗口
  2. 输入 AK/SK 后点击保存,应自动加载实例列表
  3. 实例以卡片形式展示显示名称、状态、IP、区域、规格
  4. 点击"重启"按钮弹出确认窗口
  5. 确认重启后显示成功消息
  6. 状态栏显示实例数和最后刷新时间
  • Step 3: 提交
git add .
git commit -m "test: 首次运行验证通过"