Rust-导入导出

Rust-导入导出

👉常见的库以及解决方法

Cargo.toml使用的库

javascript 复制代码
futures-util = "0.3" # 异步任务工具
csv = "1.1" # CSV文件处理
calamine = "0.21" # 解析Excel(.xlsx/.xls/.ods)
umya-spreadsheet = "1.1"

👉导出Excel

接下来我们开发一个路由接口/system/users/export,用于导出Excel文件

🍎添加路由

src\modules\user\routes.rs添加路由接口

javascript 复制代码
  // 新增导出
  cfg.route("/system/users/export", 
          web::post().to(crate::modules::user::handlers::export_users));

🍎非流式返回

接下来我们先写一个导出格式的excel,这个时候我们直接返回

javascript 复制代码
pub async fn export_users(pool: web::Data<MySqlPool>) -> HttpResponse {
    let rows = sqlx::query_as::<_, User>(
        "SELECT user_id, username, password, age, name, sex, address, state, phone, avatar, user_height, user_weight, disease
         FROM sys_user
         WHERE is_deleted = 0"
    )
    .fetch_all(pool.get_ref())
    .await;

    let users = match rows {
        Ok(v) => v,
        Err(e) => {
            eprintln!("/system/USER/export query error: {:?}", e);
            return HttpResponse::InternalServerError().json(crate::common::response::ApiResponse::<()>{
                code: 500,
                msg: "查询用户失败",
                data: None
            });
        }
    };

    // CSV 表头(中文)
    let mut csv = String::new();
    csv.push_str("用户ID,用户名,姓名,年龄,性别,电话,地址,状态,头像,身高,体重,疾病\n");

    for u in users {
        let line = vec![
            u.user_id.to_string(),
            escape_csv(&u.username),
            escape_csv(u.name.as_deref().unwrap_or("")),
            escape_csv(u.age.as_deref().unwrap_or("")),
            u.sex.unwrap_or(0).to_string(),
            escape_csv(u.phone.as_deref().unwrap_or("")),
            escape_csv(u.address.as_deref().unwrap_or("")),
            u.state.unwrap_or(0).to_string(),
            escape_csv(u.avatar.as_deref().unwrap_or("")),
            escape_csv(u.user_height.as_deref().unwrap_or("")),
            escape_csv(u.user_weight.as_deref().unwrap_or("")),
            escape_csv(u.disease.as_deref().unwrap_or("")),
        ].join(",");

        csv.push_str(&line);
        csv.push('\n');
    }

    // UTF-8 BOM,Excel 识别中文更好
    let mut bytes = Vec::from([0xEF, 0xBB, 0xBF]);
    bytes.extend_from_slice(csv.as_bytes());

    HttpResponse::Ok()
        .content_type("text/csv; charset=utf-8")
        .append_header(("Content-Disposition", "attachment; filename=\"users.csv\""))
        .body(bytes)
}

这里我们直接返回的数据,非流式,但是大型数据无法使用,返回的数据格式如下,很明显不适用于我们现在的数据

javascript 复制代码
用户ID,用户名,姓名,年龄,性别,电话,地址,状态,头像,身高,体重,疾病
44,666666,san,666666,1,187XXX77,666666,0,/uploads/images/1746683201317-nexusvue-feedback.png,,,18735797977
65,123456,嗯,,1,187357XXX977,111,0,/uploads/images/1748584154718-2.png,,,18735797977
67,admin,,20,0,admin,,0,/uploads/images/9ff7b427-fe1b-46c6-a7c9-20823aac07c0.png,,,
69,user_o_1,郝颖,34,2,13531503450,天津市正定县大厦737号,1,/avatar/default1.png,183cm,63kg,过敏
70,user_y_2,毛伟,75,1,13503536954,新疆维吾尔自治区桥西区大厦342号,1,/avatar/female1.jpg,171cm,99kg,健康
71,user_w_3,韩彬,28,1,13469265460,河南省井陉矿区园718号,1,/avatar/custom1.jpg,195cm,99kg,健康
72,user_h_4,高明,68,2,15298848187,吉林省裕华区山庄480号,1,/avatar/female1.jpg,195cm,69kg,健康
73,user_q_5,石欣,62,1,19619145883,青海省栾城区小区218号,1,/avatar/female2.jpg,173cm,99kg,健康
74,user_j_6,唐娜,22,2,15342003992,吉林省辛集市胡同498号,1,/avatar/female3.jpg,179cm,63kg,健康
75,user_q_7,林欣,56,2,15687021122,河南省辛集市坊301号,2,/avatar/female3.jpg,191cm,63kg,健康

🍎流返回Excel

接下来我们返回一个流,处理我们的Excel功能,返回类型改为分块传输(chunked 流

这里我们依然基本实现,然后优化

javascript 复制代码
// 流式导出 CSV
pub async fn export_users(pool: web::Data<MySqlPool>) -> HttpResponse {
    use actix_web::web::Bytes;
    use futures_util::stream::{self, StreamExt};

    // BOM + 表头
    let head = {
        let mut v = vec![0xEF, 0xBB, 0xBF];
        v.extend_from_slice("用户ID,用户名,姓名,年龄,性别,电话,地址,状态,头像,身高,体重,疾病\n".as_bytes());
        v
    };
    let head_stream = stream::once(async { Ok::<Bytes, actix_web::Error>(Bytes::from(head)) });

    // 分页流:每次查询一批,转换为 Bytes 后产出
    let pool = pool.get_ref().clone();
    let rows_stream = stream::unfold(Some((pool, 0i32)), |state| async move {
        let (pool, last_id) = state?;

        let rows = match sqlx::query_as::<_, User>(
            "SELECT user_id, username, password, age, name, sex, address, state, phone, avatar, user_height, user_weight, disease
             FROM sys_user
             WHERE is_deleted = 0 AND user_id > ?
             ORDER BY user_id
             LIMIT 1000"
        )
        .bind(last_id)
        .fetch_all(&pool)
        .await
        {
            Ok(v) => v,
            Err(e) => {
                eprintln!("export_users query error: {e:?}");
                return Some((Err(actix_web::error::ErrorInternalServerError("查询用户失败")), None));
            }
        };

        if rows.is_empty() {
            return None;
        }

        let mut next_last = last_id;
        let mut buf = String::with_capacity(rows.len() * 128);
        for u in rows {
            next_last = u.user_id.max(next_last);
            let line = format!(
                "{},{},{},{},{},{},{},{},{},{},{},{}\n",
                u.user_id,
                escape_csv(&u.username),
                escape_csv(u.name.as_deref().unwrap_or("")),
                escape_csv(u.age.as_deref().unwrap_or("")),
                u.sex.unwrap_or(0),
                escape_csv(u.phone.as_deref().unwrap_or("")),
                escape_csv(u.address.as_deref().unwrap_or("")),
                u.state.unwrap_or(0),
                escape_csv(u.avatar.as_deref().unwrap_or("")),
                escape_csv(u.user_height.as_deref().unwrap_or("")),
                escape_csv(u.user_weight.as_deref().unwrap_or("")),
                escape_csv(u.disease.as_deref().unwrap_or("")),
            );
            buf.push_str(&line);
        }

        Some((Ok(Bytes::from(buf)), Some((pool, next_last))))
    });

    HttpResponse::Ok()
        .content_type("application/x-www-form-urlencoded; charset=utf-8")
        .append_header(("Content-Disposition", "attachment; filename=\"users.csv\""))
        .append_header(("Access-Control-Expose-Headers", "Content-Disposition, Content-Type"))
        .streaming(head_stream.chain(rows_stream))
}

测试接口,ok 。

现在我们返回Excel 导出就没问题了

👉导出Excel模板接口system/users/exporttemplate

🍎添加路由

src\modules\user\routes.rs添加路由接口

javascript 复制代码
// src\modules\user\routes.rs
 // 导出模板
  cfg.route("/system/users/exporttemplate", 
          web::post().to(crate::modules::user::handlers::export_users_template));

🍎数据逻辑

javascript 复制代码
// 流式导出用户模板
pub async fn export_users_template() -> HttpResponse {
    use actix_web::web::Bytes;
    use futures_util::stream::{self, StreamExt};

    // BOM + 表头
    let head = {
        let mut v = vec![0xEF, 0xBB, 0xBF];
        v.extend_from_slice("用户ID,用户名,姓名,年龄,性别,电话,地址,状态,头像,身高,体重,疾病\n".as_bytes());
        v
    };
    let head_stream = stream::once(async { Ok::<Bytes, actix_web::Error>(Bytes::from(head)) });

    // 空数据流,保持结构但不查询数据
    let empty_data_stream = stream::once(async {
        Ok::<Bytes, actix_web::Error>(Bytes::from("")) // No user data, just empty content
    });

    HttpResponse::Ok()
        .content_type("application/x-www-form-urlencoded; charset=utf-8")
        .append_header(("Content-Disposition", "attachment; filename=\"empty_users_template.csv\""))
        .append_header(("Access-Control-Expose-Headers", "Content-Disposition, Content-Type"))
        .streaming(head_stream.chain(empty_data_stream))
}

测试接口 ok

🍎优化接口为标准XLSX文件

javascript 复制代码
use umya_spreadsheet::*;
use std::io::Cursor;

pub async fn export_users_template() -> HttpResponse {
  // 创建一个带默认 Sheet1 的工作簿
  let mut book = new_file();
  let sheet = book.get_sheet_by_name_mut("Sheet1").unwrap();

  // 表头
  sheet.get_cell_mut((1, 1)).set_value("用户名");
  sheet.get_cell_mut((2, 1)).set_value("姓名");
  sheet.get_cell_mut((3, 1)).set_value("年龄");
  sheet.get_cell_mut((4, 1)).set_value("电话");

  // 写到内存
  let mut buffer = Cursor::new(Vec::new());
  writer::xlsx::write_writer(&book, &mut buffer).unwrap();
  let bytes = buffer.into_inner();

  HttpResponse::Ok()
      .append_header(("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
      .append_header(("Content-Disposition", "attachment; filename=\"users_template.xlsx\""))
      .append_header(("Access-Control-Expose-Headers", "Content-Disposition, Content-Type"))
      .body(bytes)
}

👉导入Excel数据接口

system/users/import

🍎添加路由

src\modules\user\routes.rs添加路由接口

javascript 复制代码
// src\modules\user\routes.rs

// 导入数据
  cfg.route("/system/users/import",
        web::post().to(crate::modules::user::handlers::import_users));

🍎数据逻辑

接下来,我们实现一个导入用户数据的接口,你需要处理上传的 CSV 文件,并解析其中的用户数据。然后,你可以将这些数据保存到数据库中。

添加依赖

javascript 复制代码
calamine = "0.21" # 解析Excel(.xlsx/.xls/.ods)

这里我以自己最简单的数据为主,并且我们给一个uuid,确保文件唯一性

javascript 复制代码
use actix_web::{post};
use actix_multipart::Multipart;
use csv;
use csv::ReaderBuilder;
use uuid::Uuid;
use std::fs::File;
use std::io::Write;
use calamine::{open_workbook_auto, Reader, RangeDeserializerBuilder};

#[derive(Debug, Deserialize)]
struct UserRow {
    username: String,
    name: String,
    age: i32,
    phone: String,
}

pub async fn import_users(
    mut payload: Multipart,
    db_pool: web::Data<Pool<MySql>>,
) -> impl Responder {
    while let Some(item) = payload.next().await {
        let mut field = item.unwrap();
        let file_id = Uuid::new_v4().to_string();
        let file_path = format!("/uploads/tmp/{}.xlsx", file_id);

        let mut f = File::create(&file_path).unwrap();

        while let Some(chunk) = field.next().await {
            let data = chunk.unwrap();
            f.write_all(&data).unwrap();
        }

        match open_workbook_auto(&file_path) {
            Ok(mut workbook) => {
                if let Some(Ok(range)) = workbook.worksheet_range("Sheet1") {

                    let mut iter = RangeDeserializerBuilder::new()
                        .has_headers(true)
                        .from_range::<_, UserRow>(&range) // 这里指定类型
                        .unwrap();
                    let mut count = 0;
                    while let Some(Ok(row)) = iter.next() {
                        // row 已经是 UserRow 类型
                        let hashed_pwd = hash("123456", DEFAULT_COST).unwrap();

                        sqlx::query!(
                            r#"
                            INSERT INTO sys_user (username, name, age, phone, password)
                            VALUES (?, ?, ?, ?, ?)
                            "#,
                            row.username,
                            row.name,
                            row.age,
                            row.phone,
                            hashed_pwd
                        )
                        .execute(db_pool.get_ref())
                        .await
                        .unwrap();

                        count += 1;
                    }

                    return format!("成功导入 {} 条用户数据", count);
                } else {
                    return "未找到 Sheet1 或解析失败".to_string();
                }
            }
            Err(e) => {
                return format!("Excel 解析失败: {}", e);
            }
        }
    }

    "未收到文件".to_string()
}

🍎Windows系统和linux文件兼容

测试接口问题

测试一下我们的接口,发现报错如下

javascript 复制代码
thread 'actix-rt|system:0|arbiter:0' panicked at src\modules\user\handlers.rs:503:46:
called `Result::unwrap()` on an `Err` value: 
Os { code: 3, kind: NotFound, message: " 系统找不到指定的路径。" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

thread 'actix-rt|system:0|arbiter:1' panicked at src\modules\user\handlers.rs:503:46:
called `Result::unwrap()` on an `Err` 
value: Os { code: 3, kind: NotFound, message: " 系统找不到指定的路径。" }

这是因为我使用Windows系统,我写法采用的linux模式,这里我们切换一种win和linux都适用的

javascript 复制代码
// linux命令模式
let file_path = format!("/uploads/tmp/{}.xlsx", file_id);

更改以后,这里我们采用两种写法

方式一 自己创建目录
javascript 复制代码
//自己创建目录
pub async fn import_users(
    mut payload: Multipart,
    db_pool: web::Data<Pool<MySql>>,
) -> impl Responder {

    // 自己创建目录
    fs::create_dir_all("uploads").unwrap();

    while let Some(item) = payload.next().await {
        let mut field = item.unwrap();
        let file_id = Uuid::new_v4().to_string();
        let file_path = format!("uploads/tmp/{}.xlsx", file_id);
        let mut f = File::create(&file_path).unwrap();
        while let Some(chunk) = field.next().await {
            let data = chunk.unwrap();
            f.write_all(&data).unwrap();
        }
        match open_workbook_auto(&file_path) {
            Ok(mut workbook) => {
                if let Some(Ok(range)) = workbook.worksheet_range("Sheet1") {
                    let mut iter = RangeDeserializerBuilder::new()
                        .has_headers(true)
                        .from_range::<_, UserRow>(&range) // 这里指定类型
                        .unwrap();
                    let mut count = 0;
                    while let Some(Ok(row)) = iter.next() {
                        // row 已经是 UserRow 类型
                        let hashed_pwd = hash("123456", DEFAULT_COST).unwrap();

                        sqlx::query!(
                            r#"
                            INSERT INTO sys_user (username, name, age, phone, password)
                            VALUES (?, ?, ?, ?, ?)
                            "#,
                            row.username,
                            row.name,
                            row.age,
                            row.phone,
                            hashed_pwd
                        )
                        .execute(db_pool.get_ref())
                        .await
                        .unwrap();

                        count += 1;
                    }

                    return format!("成功导入 {} 条用户数据", count);
                } else {
                    return "未找到 Sheet1 或解析失败".to_string();
                }
            }
            Err(e) => {
                return format!("Excel 解析失败: {}", e);
            }
        }
    }
    "未收到文件".to_string()
}
方式二 借助临时目录

std::env::temp_dir() 获取跨平台的临时目录,然后拼接文件名

javascript 复制代码
use std::path::PathBuf;
use uuid::Uuid;

let file_id = Uuid::new_v4().to_string();
let mut file_path: PathBuf = std::env::temp_dir();
file_path.push(format!("{}.xlsx", file_id));

let mut f = File::create(&file_path).unwrap();

🍎报错处理-未找到 Sheet1 或解析失败

过程之中我们遇到了一个报错,未找到 Sheet1 或解析失败

主要写法出现就是这部分

javascript 复制代码
workbook.worksheet_range("Sheet1")

主要原因

我们的 **calamine**是严格大小写匹配表名

如果你的 Excel 里的表名是 "Sheet2""用户信息""sheet1" 或其他名字,它就会返回 None

解决方法 → 自动获取第一个 Sheet 名称

javascript 复制代码
if let Some(Ok(range)) = workbook.worksheet_range_at(0) {
    // 0 表示第一个 sheet
}

测试接口 ,上传成功,但是我们还没存入数据库之中

🍎XlsxXls格式报错

javascript 复制代码
Excel 解析失败: 
Xlsx error: Zip error: invalid Zip archive: Could not find central directory end

这是因为文件不是标准 .xlsx,我们将导出模板接口和导入都更改为标准的xlsx格式

现在的导出是 CSV ,导入是用 calamine 解析 Excel ,这两个格式不一致,

所以即使扩展名写成 .xlsx,导出文件在 WPS 或 Excel 打开没问题,但上传解析会直接报错(因为实际上是 CSV,不是 ZIP 的 .xlsx

想**完全兼容 WPS / Excel 的 ****.xlsx**,导出时就必须真的生成 .xlsx 文件,而不是伪装成 .xlsx 的 CSV。

在 Rust 里,可以用 umya-spreadsheet 这个 crate 来生成标准的 Excel 2007+ 格式文件。

完善一下,测试接口。ok

javascript 复制代码
#[derive(Debug, Deserialize)]
struct UserRow {
    #[serde(rename = "用户名")]
    username: String,
    #[serde(rename = "姓名")]
    name: String,
    #[serde(rename = "年龄")]
    age: i32,
    #[serde(rename = "电话")]
    phone: String,
}

pub async fn import_users(
    mut payload: Multipart,
    db_pool: web::Data<Pool<MySql>>,
) -> impl Responder {
    fs::create_dir_all("uploads/tmp").unwrap();

    println!("收到上传请求");

    // 打印当前连接的数据库(调试用)
    match sqlx::query!("SELECT DATABASE() AS db")
        .fetch_one(db_pool.get_ref())
        .await
    {
        Ok(db) => println!("当前连接数据库: {:?}", db.db),
        Err(e) => println!("无法获取数据库信息: {}", e),
    }

    while let Some(item) = payload.next().await {
        let mut field = match item {
            Ok(f) => f,
            Err(_) => return "文件读取失败".to_string(),
        };

        let file_id = Uuid::new_v4().to_string();
        let file_path = format!("uploads/tmp/{}.xlsx", file_id);
        let mut f = File::create(&file_path).unwrap();

        while let Some(chunk) = field.next().await {
            let data = match chunk {
                Ok(d) => d,
                Err(_) => return "文件写入失败".to_string(),
            };
            f.write_all(&data).unwrap();
        }

        // 解析 Excel
        match open_workbook_auto(&file_path) {
            Ok(mut workbook) => {
                if let Some(Ok(range)) = workbook.worksheet_range_at(0) {
                    let mut iter = RangeDeserializerBuilder::new()
                        .has_headers(true)
                        .from_range::<_, UserRow>(&range)
                        .unwrap();

                    let mut count = 0;
                    while let Some(Ok(row)) = iter.next() {
                        println!("读取到行数据: {:?}", row);

                        let hashed_pwd = match hash("123456", DEFAULT_COST) {
                            Ok(p) => p,
                            Err(e) => {
                                println!("密码加密失败: {}", e);
                                continue;
                            }
                        };

                        // 增加 is_deleted 字段
                        match sqlx::query!(
                            r#"
                            INSERT INTO sys_user (username, name, age, phone, password, is_deleted)
                            VALUES (?, ?, ?, ?, ?, '0')
                            "#,
                            row.username,
                            row.name,
                            row.age,
                            row.phone,
                            hashed_pwd
                        )
                        .execute(db_pool.get_ref())
                        .await
                        {
                            Ok(result) => {
                                println!("插入成功,影响行数: {}", result.rows_affected());
                                if result.rows_affected() > 0 {
                                    count += 1;
                                }
                            }
                            Err(e) => {
                                println!("插入失败: {}", e);
                            }
                        }
                    }
                    return format!("成功导入 {} 条用户数据", count);
                } else {
                    return "未找到工作表或解析失败".to_string();
                }
            }
            Err(e) => {
                return format!("Excel 解析失败: {}", e);
            }
        }
    }

    "未收到文件".to_string()
}

测试接口,功能ok

🍎优化提示(可用)

接下来优化一下我们接口给我们返回的提示

现在的提示

javascript 复制代码
成功导入 1 条用户数据

改成提示

javascript 复制代码
{
    "code": 200,
    "msg": "成功导入 1 条用户数据"
}

优化以后的写法

javascript 复制代码
// 版本2 
#[derive(Serialize)]
pub struct BasicImportResponse {
    pub code: i32,
    pub msg: String,
}

impl BasicImportResponse {
    pub fn new(code: i32, msg: impl Into<String>) -> Self {
        Self {
            code,
            msg: msg.into(),
        }
    }
}

#[derive(Debug, Deserialize)]
struct UserRow {
    #[serde(rename = "用户名")]
    username: String,
    #[serde(rename = "姓名")]
    name: String,
    #[serde(rename = "年龄")]
    age: i32,
    #[serde(rename = "电话")]
    phone: String,
}

pub async fn import_users(
    mut payload: Multipart,
    db_pool: web::Data<Pool<MySql>>,
) -> impl Responder {
    fs::create_dir_all("uploads/tmp").unwrap();
    println!("收到上传请求");

    while let Some(item) = payload.next().await {
        let mut field = match item {
            Ok(f) => f,
            Err(_) => {
                return web::Json(BasicImportResponse::new(400, "文件读取失败"));
            }
        };

        let file_id = Uuid::new_v4().to_string();
        let file_path = format!("uploads/tmp/{}.xlsx", file_id);
        let mut f = File::create(&file_path).unwrap();

        while let Some(chunk) = field.next().await {
            let data = match chunk {
                Ok(d) => d,
                Err(_) => {
                    return web::Json(BasicImportResponse::new(400, "文件写入失败"));
                }
            };
            f.write_all(&data).unwrap();
        }

        match open_workbook_auto(&file_path) {
            Ok(mut workbook) => {
                if let Some(Ok(range)) = workbook.worksheet_range_at(0) {
                    let mut iter = RangeDeserializerBuilder::new()
                        .has_headers(true)
                        .from_range::<_, UserRow>(&range)
                        .unwrap();

                    let mut count = 0;
                    let mut duplicated = 0;

                    while let Some(Ok(row)) = iter.next() {
                        let hashed_pwd = match hash("123456", DEFAULT_COST) {
                            Ok(p) => p,
                            Err(_) => continue,
                        };

                        match sqlx::query!(
                            r#"
                            INSERT INTO sys_user (username, name, age, phone, password, is_deleted)
                            VALUES (?, ?, ?, ?, ?, '0')
                            "#,
                            row.username,
                            row.name,
                            row.age,
                            row.phone,
                            hashed_pwd
                        )
                        .execute(db_pool.get_ref())
                        .await
                        {
                            Ok(result) => {
                                if result.rows_affected() > 0 {
                                    count += 1;
                                }
                            }
                            Err(_) => {
                                duplicated += 1;
                            }
                        }
                    }

                    let msg = if duplicated > 0 {
                        format!("成功导入 {} 条用户数据,{} 条已存在", count, duplicated)
                    } else {
                        format!("成功导入 {} 条用户数据", count)
                    };

                    return web::Json(BasicImportResponse::new(200, msg));
                } else {
                    return web::Json(BasicImportResponse::new(400, "未找到工作表或解析失败"));
                }
            }
            Err(e) => {
                return web::Json(BasicImportResponse::new(400, format!("Excel 解析失败: {}", e)));
            }
        }
    }

    web::Json(BasicImportResponse::new(400, "未收到文件"))
}

然后我们按照心意自己完善一下即可

相关推荐
用户66982061129824 分钟前
js今日理解 blob和arrayBuffer 二进制数据
前端·javascript
想想肿子会怎么做7 分钟前
Flutter 环境安装
前端·flutter
断竿散人7 分钟前
Node 版本管理工具全指南
前端·node.js
转转技术团队8 分钟前
「快递包裹」视角详解OSI七层模型
前端·面试
1024小神13 分钟前
Ant Design这个日期选择组件最大值最小值的坑
前端·javascript
卸任14 分钟前
Electron自制翻译工具:自动更新
前端·react.js·electron
安禅不必须山水15 分钟前
Express+Vercel+Github部署自己的Mock服务
前端
哈撒Ki18 分钟前
快速入门zod4
前端·node.js
小行星2号31 分钟前
阅读XXL-Job源码-服务端
后端
TG_yunshuguoji39 分钟前
阿里云国际DDoS高防:添加网站配置指南
运维·后端·阿里云