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

995 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```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**
```rust
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**
```rust
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 (骨架版本)**
```rust
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**
```rust
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**
```rust
#![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 中添加:
```toml
urlencoding = "2.1"
```
- [ ] **Step 9: 提交**
```bash
git add .
git commit -m "feat: 初始化项目结构"
```
---
### Task 2: 添加中文字体文件
**Files:**
- Create: `fonts/msyh.ttc`
- [ ] **Step 1: 复制微软雅黑字体文件**
从 Windows 系统复制字体文件:
```bash
cp /c/Windows/Fonts/msyh.ttc fonts/msyh.ttc
```
如果 `msyh.ttc` 是 TTCTrueType Collection格式egui 可能不支持。需要提取其中的 TTF 文件。备选方案:使用 `msyh.ttf` 或使用开源字体。
检查字体格式:
```bash
file fonts/msyh.ttc
```
如果是 TTC 格式,使用 `fonttools` 提取:
```bash
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` 函数替换为:
```rust
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: 提交**
```bash
git add fonts/ .gitignore
git commit -m "feat: 添加中文字体支持"
```
---
### Task 3: 完善 API 签名和错误处理
**Files:**
- Modify: `src/api.rs`
- [ ] **Step 1: 修正火山引擎签名算法**
火山引擎使用 V4 签名算法,不是简单的 HMAC-SHA256。更新 `api.rs` 中的签名逻辑:
```rust
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` 中添加:
```toml
urlencoding = "2.1"
```
- [ ] **Step 3: 验证编译**
运行: `cargo check --target x86_64-pc-windows-gnu`
- [ ] **Step 4: 提交**
```bash
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` 末尾添加:
```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: 提交**
```bash
git add Cargo.toml
git commit -m "chore: 添加 release 编译优化配置"
```
---
### Task 5: 首次运行测试
**Files:**
- 无修改
- [ ] **Step 1: 运行程序**
```bash
./target/x86_64-pc-windows-gnu/release/volcengine-server-manager.exe
```
- [ ] **Step 2: 验证行为**
1. 首次启动应弹出配置窗口
2. 输入 AK/SK 后点击保存,应自动加载实例列表
3. 实例以卡片形式展示显示名称、状态、IP、区域、规格
4. 点击"重启"按钮弹出确认窗口
5. 确认重启后显示成功消息
6. 状态栏显示实例数和最后刷新时间
- [ ] **Step 3: 提交**
```bash
git add .
git commit -m "test: 首次运行验证通过"
```