楼层: 首页/ 软件技术/ Rust 项目实战/ 项目一:企业级 Web 后端管理系统
02

项目一:企业级 Web 后端管理系统

Enterprise Admin Backend · Axum + PostgreSQL + JWT + RBAC

企业后台管理系统是后端开发的标配项目。几乎每个公司都有这么一套:员工登录后台、管理员分配角色、操作全留痕。这一章我们用 Rust 从零搭一套:Axum 做 Web 层、SQLx 操作 PostgreSQL、Redis 做缓存、JWT 鉴权、RBAC 权限,最后 Docker 一把梭上线。这套东西改改表名,就是一个能交付的毕设或练手作品。

需求分析:这个系统到底管什么

别一上来就写代码,先列功能清单。一个企业后台,核心就这几块:

模块要实现的功能
鉴权注册、登录、登出、Token 刷新(access + refresh 双 Token)
用户管理用户列表分页、新增、编辑、删除(逻辑删除)、改密码
角色权限角色 CRUD、权限分配,RBAC 模型(用户-角色-权限三级)
部门/菜单部门树形结构、菜单树、角色挂菜单、动态路由
日志/字典操作日志记录与查询、数据字典管理(带缓存)
监控在线用户、服务器状态、接口耗时统计

架构设计:四层分层,各管各的事

企业级项目最怕大泥球。我们用经典四层分层,每层只干自己的活,层与层之间靠接口/结构体打招呼:

┌─────────────────────────────────────────────┐ │ Controller 层(handlers/) 收 HTTP 请求,参数校验,返回 JSON ├─────────────────────────────────────────────┤ │ Service 层(services/) 业务逻辑,事务编排,权限判断 ├─────────────────────────────────────────────┤ │ Repository 层(repositories/) 写 SQL,操作数据库 ├─────────────────────────────────────────────┤ │ Domain 层(models/) 结构体定义:表映射、请求/响应 DTO └─────────────────────────────────────────────┘ 横切:middleware(鉴权/日志/CORS) + error.rs(统一错误) + config(配置)

数据库设计:先把地基打好

RBAC 的核心是三张表:users(用户)、roles(角色)、permissions(权限),再加两张关联表把它们串起来。下面是完整建表 DDL(PostgreSQL 方言):

migrations/0001_init.sql —— 核心表结构

-- 用户表 CREATE TABLE users ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, email VARCHAR(100) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, -- argon2 哈希,永不存明文 nickname VARCHAR(50), status SMALLINT NOT NULL DEFAULT 1, -- 1启用 0禁用 deleted SMALLINT NOT NULL DEFAULT 0, -- 逻辑删除 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- 角色表 CREATE TABLE roles ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, code VARCHAR(50) NOT NULL UNIQUE, -- ADMIN / USER name VARCHAR(50) NOT NULL, remark VARCHAR(255) ); -- 权限表(菜单/按钮/接口三类) CREATE TABLE permissions ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, code VARCHAR(100) NOT NULL UNIQUE, -- user:list / user:create name VARCHAR(50) NOT NULL, type VARCHAR(20) NOT NULL -- menu / button / api ); -- 用户-角色 关联表(多对多) CREATE TABLE user_roles ( user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, PRIMARY KEY (user_id, role_id) ); -- 角色-权限 关联表 CREATE TABLE role_permissions ( role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, permission_id BIGINT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, PRIMARY KEY (role_id, permission_id) ); -- 操作日志表 CREATE TABLE operation_logs ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, username VARCHAR(50), module VARCHAR(50) NOT NULL, action VARCHAR(50) NOT NULL, path VARCHAR(255), ip VARCHAR(50), cost_ms INT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_log_user ON operation_logs(username); CREATE INDEX idx_log_created ON operation_logs(created_at);
SQLx 迁移怎么跑

别手动进数据库敲 SQL。用 SQLx 自带的迁移:文件放 migrations/ 目录,主程序里一行 sqlx::migrate!("./migrations").run(&pool).await? 就自动按文件名顺序执行。好处是换台机器、换个数据库,跑起来就自动建表,Docker 部署时尤其省心。文件名前缀用 0001_、0002_ 保证执行顺序。

项目初始化:Cargo.toml 与目录结构

先把依赖一次配好。下面这份 Cargo.toml 就是项目一的全部家底(PostgreSQL 版):

Cargo.toml

[package] name = "rust-admin" version = "0.1.0" edition = "2024" [dependencies] # Web 框架 + 异步运行时 axum = { version = "0.8", features = ["macros"] } tokio = { version = "1", features = ["full"] } # 数据库(PostgreSQL)+ 缓存 sqlx = { version = "0.8", features = ["postgres", "runtime-tokio", "chrono", "uuid"] } redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } # 序列化 + 参数校验 serde = { version = "1", features = ["derive"] } serde_json = "1" validator = { version = "0.20", features = ["derive"] } # 日志 + 错误处理 tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } thiserror = "2" anyhow = "1" # JWT + 密码哈希(argon2) jsonwebtoken = "9" argon2 = { version = "0.5", features = ["std"] } password-hash = { version = "0.5", features = ["std"] } # 配置 + 时间 + UUID + 中间件 config = { version = "0.15", features = ["toml"] } dotenvy = "0.15" chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4", "serde"] } tower-http = { version = "0.6", features = ["cors", "trace"] } [profile.release] opt-level = 3 lto = "thin" codegen-units = 1 strip = true

目录结构(企业级标准分层)

rust-admin/ ├── Cargo.toml ├── .env # 环境变量(不进 git) ├── config/default.toml # 应用配置 ├── migrations/ # SQLx 迁移文件 │ ├── 0001_init.sql │ └── 0002_dict.sql └── src/ ├── main.rs # 入口 ├── error.rs # 统一错误类型 ├── config.rs # 配置结构体 ├── models/ # 第 4 层:数据模型 + DTO ├── repositories/ # 第 3 层:写 SQL ├── services/ # 第 2 层:业务逻辑 ├── handlers/ # 第 1 层:HTTP 接口 ├── middleware/ # 鉴权 / 日志 / CORS └── utils/ # jwt / 响应封装

核心一:配置管理(对标 SpringBoot 的 application.yml)

配置别写死在代码里。用 config crate 读 config.toml,再让环境变量覆盖——生产环境改配置不用重新编译。

src/config.rs

use serde::Deserialize; /// 应用总配置(强类型,读错字段编译期就报错) #[derive(Debug, Clone, Deserialize)] pub struct AppConfig { pub server: ServerConfig, pub database: DatabaseConfig, pub redis: RedisConfig, pub jwt: JwtConfig, } #[derive(Debug, Clone, Deserialize)] pub struct ServerConfig { pub host: String, pub port: u16 } #[derive(Debug, Clone, Deserialize)] pub struct DatabaseConfig { pub url: String, pub max_connections: u32, } #[derive(Debug, Clone, Deserialize)] pub struct RedisConfig { pub url: String } #[derive(Debug, Clone, Deserialize)] pub struct JwtConfig { pub secret: String, pub access_expire_secs: i64, } impl AppConfig { /// 加载:先读 config/default.toml,再用 APP__ 前缀的环境变量覆盖 pub fn load() -> anyhow::Result<Self> { dotenvy::dotenv().ok(); let cfg = config::Config::builder() .add_source(config::File::with_name("config/default")) // 环境变量如 APP__DATABASE__URL 优先级更高,方便容器注入 .add_source(config::Environment::with_prefix("APP").separator("__")) .build()?; Ok(cfg.try_deserialize()?) } }

核心二:数据库连接池 + Redis

数据库连接是稀缺资源,不能每次请求都新建连接(那样慢死)。用连接池复用连接——对标 Java 的 HikariCP。

src/main.rs(入口与连接池初始化)

mod config; mod error; mod handlers; mod middleware; mod models; mod repositories; mod services; mod utils; use sqlx::postgres::PgPoolOptions; use std::sync::Arc; /// 应用全局状态:Axum 把它 clone 给每个请求(连接池是 Arc 包裹,clone 很便宜) #[derive(Clone)] pub struct AppState { pub config: Arc<config::AppConfig>, pub pg: sqlx::PgPool, pub redis: redis::aio::ConnectionManager, } #[tokio::main] async fn main() -> anyhow::Result<()> { // 1. 初始化日志(对标 logback) tracing_subscriber::fmt().with_env_filter("info").init(); // 2. 读配置 let config = Arc::new(config::AppConfig::load()?); // 3. PostgreSQL 连接池 let pg = PgPoolOptions::new() .max_connections(config.database.max_connections) .connect(&config.database.url) .await?; // 跑迁移(对标 Flyway) sqlx::migrate!("./migrations").run(&pg).await?; tracing::info!("数据库就绪"); // 4. Redis 连接(ConnectionManager 自带断线重连) let redis_client = redis::Client::open(config.redis.url.as_str())?; let redis = redis::aio::ConnectionManager::new(redis_client).await?; tracing::info!("Redis 就绪"); // 5. 组路由并启动 let state = AppState { config, pg, redis }; let app = handlers::router(state); let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?; tracing::info!("服务启动: http://0.0.0.0:8080"); axum::serve(listener, app).await?; Ok(()) }
$ cargo run [INFO] 数据库就绪 [INFO] Redis 就绪 [INFO] 服务启动: http://0.0.0.0:8080

核心三:统一错误处理(对标 @RestControllerAdvice)

Java 里你写一堆 try-catch 抛异常。Rust 更优雅:定义一个 AppError 枚举,所有错误都变成它,再实现 IntoResponse 自动转成 JSON。一处定义,全局生效。

src/error.rs

use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; use serde::Serialize; /// 业务错误码 #[derive(Debug, thiserror::Error)] pub enum AppError { #[error("业务错误: {0}")] Business(u16, String), // (code, message) #[error("数据库错误: {0}")] Database(#[from] sqlx::Error), #[error("JWT错误: {0}")] Jwt(#[from] jsonwebtoken::errors::Error), #[error("参数校验失败: {0}")] Validation(String), #[error(transparent)] Other(#[from] anyhow::Error), } /// 统一响应体:前端拿到的永远是这个结构 #[derive(Debug, Serialize)] pub struct ApiResponse<T> { code: i32, message: String, data: Option<T>, } impl<T: Serialize> ApiResponse<T> { pub fn ok(data: T) -> Self { Self { code: 0, message: "成功".into(), data: Some(data) } } } /// 关键一步:错误自动转成 HTTP 响应 impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, code, msg) = match self { AppError::Business(c, m) => ( match c { 401 => StatusCode::UNAUTHORIZED, 403 => StatusCode::FORBIDDEN, 404 => StatusCode::NOT_FOUND, _ => StatusCode::BAD_REQUEST }, c as i32, m), AppError::Database(_) => { tracing::error!("数据库异常: {self}"); (StatusCode::INTERNAL_SERVER_ERROR, 500, "数据库错误".into()) } e => { (StatusCode::INTERNAL_SERVER_ERROR, 500, e.to_string()) } }; (status, Json(ApiResponse<()> { code, message: msg, data: None })).into_response() } }

妙这招为什么这么爽

注意那个 #[from] sqlx::Error——它让数据库错误自动"升级"成 AppError。于是业务代码里你只写 Result<T, AppError>,数据库出错、JWT 出错,直接用 ? 问号操作符往上抛,框架自动接住并翻译成统一 JSON。不用到处 try-catch,错误处理代码少写一半。这就是 Rust 错误处理比 Java 优雅的地方。

核心四:JWT 鉴权与登录(argon2 哈希密码)

密码永远不能明文存。我们用 argon2(目前最推荐的密码哈希算法,比 bcrypt 新)。登录成功后签发 access token。

src/utils/jwt.rs

use chrono::Utc; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] pub struct Claims { pub sub: i64, // 用户ID pub username: String, pub exp: usize, // 过期时间戳 } pub fn make_token(secret: &str, user_id: i64, username: &str, expire_secs: i64) -> Result<String, jsonwebtoken::errors::Error> { let exp = (Utc::now() + chrono::Duration::seconds(expire_secs)).timestamp() as usize; let claims = Claims { sub: user_id, username: username.into(), exp }; encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes())) } pub fn verify_token(secret: &str, token: &str) -> Result<Claims, jsonwebtoken::errors::Error> { let data = decode::<Claims>(token, &DecodingKey::from_secret(secret.as_bytes()), &Validation::default())?; Ok(data.claims) }

src/services/auth_service.rs(注册 + 登录)

use argon2::{password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2}; use crate::error::AppError; /// 密码哈希:加盐,每次盐都随机 pub fn hash_password(raw: &str) -> Result<String, AppError> { let salt = SaltString::generate(&mut OsRng); let hash = Argon2::default().hash_password(raw.as_bytes(), &salt)?.to_string(); Ok(hash) } /// 校验密码:把明文和存的哈希比一比 pub fn verify_password(raw: &str, hash: &str) -> Result<bool, AppError> { let parsed = PasswordHash::new(hash)?; Ok(Argon2::default().verify_password(raw.as_bytes(), &parsed).is_ok()) } /// 登录:查用户 → 查状态 → 验密码 → 发 token pub async fn login(pg: &sqlx::PgPool, secret: &str, username: &str, password: &str) -> Result<String, AppError> { let user = sqlx::query_as::<_, User>( "SELECT * FROM users WHERE username = $1 AND deleted = 0") .bind(username) .fetch_optional(pg).await? .ok_or_else(|| AppError::Business(401, "用户名或密码错误".into()))?; if user.status != 1 { return Err(AppError::Business(403, "账号已禁用".into())); } if !verify_password(password, &user.password_hash)? { return Err(AppError::Business(401, "用户名或密码错误".into())); } let token = make_token(secret, user.id, &user.username, 7200)?; Ok(token) }
两个登录失败提示为什么要写成一样

看上面:用户名不存在、密码错误,返回的都是同一句"用户名或密码错误"。这是故意的——如果区分"用户名不存在"和"密码错误",黑客就能拿你的接口枚举哪些用户名真实存在(撞库前置探测)。安全的做法是让攻击者分不出来。这种细节,Java 老炮写 SpringSecurity 时也讲究,Rust 项目照样不能漏。

核心五:鉴权中间件(Extractor 自动塞当前用户)

Axum 最妙的设计是 Extractor:你只要在 handler 参数里写个类型,它就自动从请求里把数据解出来。我们写个 AuthUser,自动校验 JWT、把当前用户塞进 handler。

src/middleware/auth.rs

use axum::{extract::{FromRef, FromRequestParts}, http::request::Parts}; use crate::{AppState, utils::jwt::verify_token, error::AppError}; /// 鉴权后自动注入的当前用户 #[derive(Debug, Clone)] pub struct AuthUser { pub id: i64, pub username: String } /// 实现提取器:handler 里写 AuthUser,它就自动校验 token impl FromRequestParts<AppState> for AuthUser { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> { // 1. 从 Authorization 头拿 Bearer token let header = parts.headers.get("Authorization") .and_then(|v| v.to_str().ok()) .ok_or_else(|| AppError::Business(401, "未登录".into()))?; let token = header.strip_prefix("Bearer ") .ok_or_else(|| AppError::Business(401, "Token格式错".into()))?; // 2. 验签 + 取 claims let claims = verify_token(&state.config.jwt.secret, token)?; Ok(AuthUser { id: claims.sub, username: claims.username }) } }

用它保护接口——handler 直接加个参数就行

/// GET /api/users/me —— 只有登录用户能访问 pub async fn get_me( State(state): State<AppState>, auth: AuthUser, // 这一行就完成了鉴权! ) -> Result<Json<ApiResponse<User>>, AppError> { let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1") .bind(auth.id) .fetch_one(&state.pg).await?; Ok(Json(ApiResponse::ok(user))) }
$ curl http://localhost:8080/api/users/me { "code": 401, "message": "未登录", "data": null } $ curl -H "Authorization: Bearer eyJhbGciOi..." http://localhost:8080/api/users/me { "code": 0, "message": "成功", "data": { "id": 1, "username": "admin" } }

API 设计规范:统一响应与分页

接口一多,前端最烦的就是"每个接口返回格式都不一样"。所以我们定死两条规矩:所有响应都是 ApiResponse 包裹,所有列表接口都带分页。

操作方法 + 路径说明
注册/登录POST /api/auth/register无需鉴权
用户列表GET /api/users?page=1&size=10需 user:list 权限
用户详情GET /api/users/:idRESTful,资源在路径里
修改用户PUT /api/users/:id全量更新
删除用户DELETE /api/users/:id逻辑删除

接口文档用 utoipa 自动生成:给 handler 加几个 derive,启动后访问 /swagger-ui 就能看到在线文档,还能直接点按钮发请求调试。前后端联调再也不用嘴对嘴。

其余模块一句话带过(套路都一样)

模块实现套路
角色权限 RBAC登录后把 user→roles→permissions 查出权限码集合,存进 Redis。中间件里查 has_permission("user:delete")。
部门管理表加 parent_id 自关联,一次全查出在内存里递归拼成树。
菜单管理同部门树形结构,角色挂 menu_id 列表,前端拿到后动态生成路由。
操作日志写个 middleware:请求进来记开始时间,next.run(req).await 跑完算耗时,异步写 operation_logs 表。
数据字典字典类型/字典项 CRUD,热点字典加载进 Redis 缓存,改了就失效缓存。
系统监控在线用户数查 Redis、服务器状态用 sysinfo crate、接口耗时从日志统计。

加分项:Redis 缓存 + 分页查询 + 日志中间件

上面那几行"一句话带过"的模块,挑三个最常用的,把代码补全。企业后台里字典数据读多写少,天生适合缓存。

用 Redis 缓存字典(读缓存 -> 没有再查库 -> 写回缓存)

/// 查字典:先查 Redis,miss 了再查 PG,然后回填缓存 pub async fn get_dict(redis: &mut redis::aio::ConnectionManager, pg: &sqlx::PgPool, code: &str) -> anyhow::Result<Vec<DictItem>> { let key = format!("dict:{code}"); // 1. 先试缓存 let cached: Option<String> = redis::cmd("GET").arg(&key).query_async(redis).await?; if let Some(json) = cached { return Ok(serde_json::from_str(&json)?); } // 2. 缓存 miss,查数据库 let items = sqlx::query_as::<_, DictItem>( "SELECT * FROM dict_items WHERE type_code = $1 ORDER BY sort") .bind(code).fetch_all(pg).await?; // 3. 回填缓存,TTL 5 分钟 let json = serde_json::to_string(&items)?; redis::cmd("SET").arg(&key).arg(&json) .arg("EX").arg(300).query_async(redis).await?; Ok(items) }

分页查询(动态拼 WHERE,LIMIT/OFFSET)

/// GET /api/users?page=1&page_size=10&keyword=张 pub async fn page_users(pg: &sqlx::PgPool, page: u64, size: u64, keyword: Option<&str>) -> Result<PageResult<User>, AppError> { let page = page.max(1); let size = size.clamp(1, 100); // 防一次查 10000 条 let offset = (page - 1) * size; // 总数 let total: i64 = sqlx::query_scalar( "SELECT count(*) FROM users WHERE deleted = 0 AND ($1::text IS NULL OR username LIKE '%'||$1||'%')") .bind(keyword).fetch_one(pg).await?; // 当前页数据 let list = sqlx::query_as::<_, User>( "SELECT * FROM users WHERE deleted = 0 AND ($1::text IS NULL OR username LIKE '%'||$1||'%') ORDER BY id DESC LIMIT $2 OFFSET $3") .bind(keyword).bind(size).bind(offset) .fetch_all(pg).await?; Ok(PageResult { total, page, page_size: size, list }) }

操作日志中间件(对标 Spring AOP 环绕通知)

use axum::{extract::{Request, State}, middleware::Next, response::Response}; use std::time::Instant; /// 记录每个请求的耗时和路径,异步写库 pub async fn access_log(State(state): State<AppState>, req: Request, next: Next) -> Response { let start = Instant::now(); let method = req.method().clone(); let path = req.uri().path().to_string(); let resp = next.run(req).await; // 继续往后走 let cost = start.elapsed().as_millis() as i64; tracing::info!(method = %method, path = %path, cost_ms = cost, "请求完成"); // 生产:异步写 operation_logs 表,不阻塞响应 resp }
$ curl http://localhost:8080/api/users [INFO] method=GET path=/api/users cost_ms=3 请求完成 { "code": 0, "data": { "total": 42, "page": 1, "list": [...] } }

测试:单元测试 + 集成测试

Rust 的测试是内置在标准库的,不用装任何框架——cargo test 一把梭。单元测试写在源码同目录,集成测试写在 tests/ 目录。

单元测试(写在 utils/jwt.rs 底部)

#[cfg(test)] mod tests { use super::*; #[test] fn token_roundtrip() { let token = make_token("secret", 1, "admin", 3600).unwrap(); let claims = verify_token("secret", &token).unwrap(); assert_eq!(claims.sub, 1); assert_eq!(claims.username, "admin"); } #[test] fn expired_token_rejected() { let token = make_token("secret", 1, "admin", -1).unwrap(); // 已过期 assert!(verify_token("secret", &token).is_err()); } #[test] fn password_hash_and_verify() { let hash = hash_password("abc123").unwrap(); assert!(verify_password("abc123", &hash).unwrap()); assert!(!verify_password("wrong", &hash).unwrap()); } }

集成测试(tests/api_test.rs,对标 MockMvc)

use tower::ServiceExt; // oneshot #[tokio::test] async fn register_then_login() { let app = build_test_app().await; // 1. 注册 let resp = app.clone().oneshot( Request::builder().method("POST").uri("/api/auth/register") .header("Content-Type", "application/json") .body(Body::from(r#"{"username":"u1","email":"u@a.com","password":"abc123"}"#)).unwrap() ).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); // 2. 登录,应拿到 access_token let resp = app.oneshot( Request::builder().method("POST").uri("/api/auth/login") .header("Content-Type", "application/json") .body(Body::from(r#"{"username":"u1","password":"abc123"}"#)).unwrap() ).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); }
$ cargo test Compiling rust-admin v0.1.0 Running tests/api_test.rs test register_then_login ... ok test result: ok. 2 passed; 0 failed

部署:Docker 多阶段构建,镜像只要 30MB

Rust 编译出来是静态二进制,不依赖运行时(不像 Java 还得带个 JRE)。多阶段构建:第一阶段编译,第二阶段只把二进制抠出来。

Dockerfile

# 阶段一:编译 FROM rust:1.90-bookworm AS builder WORKDIR /app # 先只拷依赖清单编译一次,利用 Docker 层缓存 COPY Cargo.toml Cargo.lock ./ RUN mkdir src && echo "fn main() {}" > src/main.rs \ && cargo build --release && rm -rf src # 再拷真源码正式编译 COPY src ./src COPY migrations ./migrations RUN cargo build --release # 阶段二:运行(对标 JRE 精简镜像) FROM debian:bookworm-slim WORKDIR /app RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/target/release/rust-admin . COPY config ./config COPY migrations ./migrations EXPOSE 8080 CMD ["./rust-admin"]

docker-compose.yml(app + PostgreSQL + Redis 一把起)

services: postgres: image: postgres:16 environment: POSTGRES_PASSWORD: secret POSTGRES_DB: admin volumes: [pg_data:/var/lib/postgresql/data] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 5s retries: 10 redis: image: redis:7-alpine app: build: . depends_on: postgres: { condition: service_healthy } environment: APP__DATABASE__URL: "postgres://postgres:secret@postgres:5432/admin" APP__REDIS__URL: "redis://redis:6379" ports: ["8080:8080"] volumes: pg_data:
$ docker compose up -d [+] Started 3/3 $ curl http://localhost:8080/health { "status": "ok" }

Nginx 反向代理(对外只暴露 80/443,Rust 藏在后面)

server { listen 80; server_name api.example.com; # 把 /api 开头的请求转给本机 8080 的 Rust 服务 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # SSE 流式输出必须关缓冲,否则前端收不到逐字 location /api/chat/stream { proxy_pass http://127.0.0.1:8080; proxy_buffering off; # 关键!不关缓冲流式会卡住 proxy_cache off; proxy_read_timeout 300s; } }
为什么 Nginx 要放前面: SSL 终止、限流、静态资源、多实例负载均衡,都交给 Nginx,Rust 只管业务。

.github/workflows/ci.yml(提交代码自动检查+测试+构建)

name: CI on: { push: { branches: [main] }, pull_request: {} } jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: { components: [rustfmt, clippy] } - uses: Swatinem/rust-cache@v2 - name: 格式检查 run: cargo fmt --check - name: Clippy(零警告) run: cargo clippy -- -D warnings - name: 跑测试 run: cargo test
为什么要先写个空 main.rs 编译一遍

这是 Docker 构建加速的经典技巧。Rust 编译依赖极慢(axum + sqlx 全套下来首次编译要几分钟)。如果每次改一行代码都重新装依赖,CI 会跑死。先只拷 Cargo.toml 编个空壳,这一层有缓存,之后只要依赖没变,Docker 直接复用,真正编译你的业务代码只要几十秒。这个 trick 几乎每个 Rust Dockerfile 都在用。

记
本章小结

① 四层架构 handlers → services → repositories → models,层与层不越界。

② 统一错误:thiserror 枚举 + IntoResponse,问号操作符一路 ? 到底。

③ 密码用 argon2 加盐哈希,登录失败提示统一;JWT 用 Extractor 自动鉴权。

④ 部署用 Docker 多阶段构建,最终镜像约 30MB,compose 一把起 app + PG + Redis。