Files
erp/docs/superpowers/plans/2026-04-17-platform-maturity-q2-plan.md
iven 5e89aef99f docs: 修订 Q2 实施计划 — 修复审查发现的 14 个问题
关键修正:
- AuditLog::new() 签名与代码库一致 (tenant_id, user_id, action, resource_type)
- with_changes() 参数为 Option<Value>,调用时需 Some() 包装
- 限流 fail-closed 使用 (StatusCode, Json) 元组模式
- 添加缺失的 Task 7.5: 登录租户解析 (X-Tenant-ID)
- Task 7 添加 assign_roles 到修复列表
- Task 10 明确所有 auth 函数签名变更需求
- Task 12 添加 import 和参数说明
- Task 14 添加 docker-compose.override.yml 开发端口恢复
- 统一环境变量名为 ERP__SUPER_ADMIN_PASSWORD
2026-04-17 17:02:03 +08:00

872 lines
24 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.

# Q2 安全地基 + CI/CD 实施计划
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 消除所有 CRITICAL/HIGH 安全风险,建立 CI/CD 自动化质量门,完成审计日志补全和 Docker 生产化。
**Architecture:** 密钥外部化通过环境变量强制注入 + 启动检查拒绝默认值CI/CD 使用 Gitea Actions 四 job 并行;限流改为 fail-closed审计日志补全 IP/UA 和变更值。
**Tech Stack:** Rust (Axum, SeaORM), Gitea Actions, Docker Compose, PostgreSQL 16, Redis 7
**Spec:** `docs/superpowers/specs/2026-04-17-platform-maturity-roadmap-design.md` §2
---
## File Structure
| 操作 | 文件 | 职责 |
|------|------|------|
| Modify | `crates/erp-server/config/default.toml` | 敏感值改为占位符 |
| Modify | `crates/erp-server/src/main.rs` | 启动时拒绝默认密钥 |
| Modify | `crates/erp-auth/src/module.rs:149-150` | 移除密码 fallback |
| Modify | `crates/erp-auth/src/error.rs:46-53` | 移除 `From<AppError> for AuthError` 反向映射 |
| Modify | `crates/erp-auth/src/service/auth_service.rs:177-181` | refresh 添加 tenant_id 过滤 |
| Modify | `crates/erp-auth/src/service/user_service.rs` | get_by_id/update/delete 改为 DB 级 tenant 过滤 |
| Modify | `crates/erp-server/src/middleware/rate_limit.rs:122-124,135-137` | fail-closed |
| Modify | `crates/erp-core/src/audit.rs` | `with_request_info` 类型扩展 |
| Modify | `crates/erp-auth/src/service/auth_service.rs` | login/logout/change_password 添加审计 |
| Modify | `crates/erp-plugin/src/data_service.rs` | CRUD 操作添加审计 |
| Modify | `docker/docker-compose.yml` | 端口不暴露、Redis 密码、资源限制 |
| Modify | `.gitignore` | 添加 `.test_token` |
| Create | `.gitea/workflows/ci.yml` | CI/CD 流水线 |
---
## Chunk 1: 密钥外部化与启动强制检查
### Task 1: 清理 `.test_token` 和 `.gitignore`
**Files:**
- Modify: `.gitignore`
- Delete: `.test_token`(仅本地文件)
- [ ] **Step 1: 验证 `.test_token` 是否曾提交到 git 历史**
```bash
git log --all --oneline -- .test_token
```
Expected: 无输出(从未提交)。如果有输出,需额外执行 BFG 清理。
- [ ] **Step 2: 添加 `.test_token` 到 `.gitignore`**
`.gitignore` 末尾添加:
```
# Test artifacts
.test_token
*.heapsnapshot
perf-trace-*.json
```
- [ ] **Step 3: Commit**
```bash
git add .gitignore
git commit -m "chore: 添加 .test_token 和测试产物到 .gitignore"
```
---
### Task 2: `default.toml` 敏感值改为占位符
**Files:**
- Modify: `crates/erp-server/config/default.toml`
- [ ] **Step 1: 替换敏感值**
`crates/erp-server/config/default.toml` 中的:
```toml
url = "postgres://erp:erp_dev_2024@localhost:5432/erp"
```
改为:
```toml
url = "__MUST_SET_VIA_ENV__"
```
将:
```toml
secret = "change-me-in-production"
```
改为:
```toml
secret = "__MUST_SET_VIA_ENV__"
```
将:
```toml
super_admin_password = "Admin@2026"
```
改为:
```toml
super_admin_password = "__MUST_SET_VIA_ENV__"
```
- [ ] **Step 2: 创建 `.env.development` 供本地开发使用**
在项目根目录创建 `.env.development`(已被 `.gitignore``*.env.local` 覆盖,但需显式添加 `.env.development`
```
# .env.development — 本地开发用,不提交到仓库
# 注意:此文件需要手动 source 或通过 dotenv 工具加载config crate 不会自动读取
ERP__DATABASE__URL=postgres://erp:erp_dev_2024@localhost:5432/erp
ERP__JWT__SECRET=dev-local-secret-change-me
ERP__SUPER_ADMIN_PASSWORD=Admin@2026
```
更新 `.gitignore`,添加 `.env.development`
- [ ] **Step 3: Commit**
```bash
git add crates/erp-server/config/default.toml .gitignore
git commit -m "fix(security): default.toml 敏感值改为占位符,强制通过环境变量注入"
```
---
### Task 3: 启动检查 — 拒绝默认密钥
**Files:**
- Modify: `crates/erp-server/src/main.rs`(在服务启动前添加检查)
- [ ] **Step 1: 在 `main.rs` 的配置加载后、服务启动前添加安全检查**
在配置加载完成后(`let config = ...` 之后),添加:
```rust
// ── 安全检查:拒绝默认密钥 ──────────────────────────
if config.jwt.secret == "__MUST_SET_VIA_ENV__" || config.jwt.secret == "change-me-in-production" {
tracing::error!(
"JWT 密钥为默认值,拒绝启动。请设置环境变量 ERP__JWT__SECRET"
);
std::process::exit(1);
}
if config.database.url == "__MUST_SET_VIA_ENV__" {
tracing::error!(
"数据库 URL 为默认占位值,拒绝启动。请设置环境变量 ERP__DATABASE__URL"
);
std::process::exit(1);
}
```
- [ ] **Step 2: 验证默认配置启动被拒绝**
```bash
ERP__JWT__SECRET="__MUST_SET_VIA_ENV__" cargo run -p erp-server
```
Expected: 进程退出,输出包含 "JWT 密钥为默认值,拒绝启动"
- [ ] **Step 3: 验证环境变量设置后正常启动**
```bash
ERP__JWT__SECRET="my-real-secret" ERP__DATABASE__URL="postgres://erp:erp_dev_2024@localhost:5432/erp" ERP__AUTH__SUPER_ADMIN_PASSWORD="TestPass123" cargo run -p erp-server
```
Expected: 服务正常启动(或因数据库未运行而失败,但不应因安全检查退出)
- [ ] **Step 4: Commit**
```bash
git add crates/erp-server/src/main.rs
git commit -m "fix(security): 启动时拒绝默认 JWT 密钥和数据库 URL"
```
---
### Task 4: 移除密码 fallback 硬编码
**Files:**
- Modify: `crates/erp-auth/src/module.rs:149-150`
- [ ] **Step 1: 将 `unwrap_or_else` 改为显式错误处理**
将:
```rust
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD")
.unwrap_or_else(|_| "Admin@2026".to_string());
```
改为:
```rust
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD")
.map_err(|_| {
tracing::error!("环境变量 ERP__SUPER_ADMIN_PASSWORD 未设置,无法初始化租户认证");
erp_core::error::AppError::Internal(
"ERP__SUPER_ADMIN_PASSWORD 未设置".to_string(),
)
})?;
```
- [ ] **Step 2: 验证编译通过**
```bash
cargo check -p erp-auth
```
Expected: 编译成功
- [ ] **Step 3: Commit**
```bash
git add crates/erp-auth/src/module.rs
git commit -m "fix(security): 移除 super_admin_password 硬编码 fallback"
```
---
### Task 5: 移除 `From<AppError> for AuthError` 反向映射
**Files:**
- Modify: `crates/erp-auth/src/error.rs:46-53`
- [ ] **Step 1: 删除反向映射 impl**
删除 `crates/erp-auth/src/error.rs` 中的整个 impl 块:
```rust
// 删除以下代码
impl From<AppError> for AuthError {
fn from(err: AppError) -> Self {
match err {
AppError::VersionMismatch => AuthError::VersionMismatch,
other => AuthError::Validation(other.to_string()),
}
}
}
```
- [ ] **Step 2: 修复所有依赖此反向映射的调用点**
反向映射主要用于 `on_tenant_created` / `on_tenant_deleted` 中。检查这两个函数 — 它们已经返回 `AppResult<()>`(不是 `AuthResult`),所以不会直接受影响。
真正受影响的是 `auth_service.rs` 中可能从其他 crate 传入 `AppError` 并隐式转为 `AuthError` 的路径。逐一检查:
- `auth_service.rs` — 所有 `.map_err()` 调用是否仍能编译
- `user_service.rs` — 同上
- 如果有编译错误,在调用点使用显式 `.map_err(|e| AuthError::Validation(e.to_string()))` 而非依赖隐式转换
- [ ] **Step 3: 删除反向映射的测试**
删除 `crates/erp-auth/src/error.rs` 测试中的 `app_error_version_mismatch_roundtrip``app_error_other_maps_to_auth_validation` 测试。
- [ ] **Step 4: 验证编译和测试**
```bash
cargo check -p erp-auth && cargo test -p erp-auth
```
Expected: 编译成功,所有测试通过
- [ ] **Step 5: Commit**
```bash
git add crates/erp-auth/src/
git commit -m "refactor(auth): 移除 From<AppError> for AuthError 反向映射"
```
---
## Chunk 2: 多租户安全加固 + 限流 fail-closed
### Task 6: `auth_service::refresh()` 添加 tenant_id 过滤
**Files:**
- Modify: `crates/erp-auth/src/service/auth_service.rs:177-181`
- [ ] **Step 1: 修改 refresh 中的用户查询**
将:
```rust
let user_model = user::Entity::find_by_id(claims.sub)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or(AuthError::TokenRevoked)?;
```
改为:
```rust
let user_model = user::Entity::find_by_id(claims.sub)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or(AuthError::TokenRevoked)?;
// 验证用户属于 JWT 中声明的租户
// 注意JWT claims 中租户 ID 字段名为 `tid`(与 TokenService 签发时一致)
if user_model.tenant_id != claims.tid {
tracing::warn!(
user_id = %claims.sub,
jwt_tenant = %claims.tid,
actual_tenant = %user_model.tenant_id,
"Token tenant_id 与用户实际租户不匹配"
);
return Err(AuthError::TokenRevoked);
}
```
- [ ] **Step 2: 验证编译**
```bash
cargo check -p erp-auth
```
- [ ] **Step 3: Commit**
```bash
git add crates/erp-auth/src/service/auth_service.rs
git commit -m "fix(auth): refresh token 流程添加 tenant_id 校验"
```
---
### Task 7: `user_service` 改为 DB 级 tenant_id 过滤
**Files:**
- Modify: `crates/erp-auth/src/service/user_service.rs``get_by_id``update``delete``assign_roles` 四个函数)
- [ ] **Step 1: 修改 `get_by_id`(约第 129-134 行)**
`find_by_id` + 内存 `.filter()` 模式改为数据库级查询:
```rust
pub async fn get_by_id(id: Uuid, tenant_id: Uuid, db: &DatabaseConnection) -> AuthResult<user::Model> {
user::Entity::find()
.filter(user::Column::Id.eq(id))
.filter(user::Column::TenantId.eq(tenant_id))
.filter(user::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or(AuthError::Validation("用户不存在".to_string()))
}
```
- [ ] **Step 2: 同样修改 `update`、`delete` 和 `assign_roles` 函数**
将这三个函数中的 `find_by_id` + 内存 `.filter()` 改为相同的 DB 级过滤模式。注意:`login``list` 函数已正确使用数据库级过滤,无需修改。
- [ ] **Step 3: 验证编译和测试**
```bash
cargo check -p erp-auth && cargo test -p erp-auth
```
- [ ] **Step 4: Commit**
```bash
git add crates/erp-auth/src/service/user_service.rs
git commit -m "fix(auth): get_by_id/update/delete 改为数据库级 tenant_id 过滤"
```
---
### Task 7.5: 登录租户解析
**Files:**
- Modify: `crates/erp-auth/src/handler/auth_handler.rs`(登录 handler 提取租户信息)
- Modify: `crates/erp-auth/src/service/auth_service.rs`login 函数签名调整)
- [ ] **Step 1: 在 `auth_handler.rs` 的 `login` handler 中提取租户 ID**
从请求头 `X-Tenant-ID` 提取租户 ID若无此头则使用默认租户向后兼容
```rust
let tenant_id = headers
.get("X-Tenant-ID")
.and_then(|v| v.to_str().ok())
.and_then(|v| Uuid::parse_str(v).ok())
.unwrap_or(state.default_tenant_id);
```
`tenant_id` 传入 `AuthService::login`
- [ ] **Step 2: 更新 `AuthService::login` 签名**
如果当前签名不含 `tenant_id` 参数,添加 `tenant_id: Uuid` 参数,替换函数内部对 `state.default_tenant_id` 的使用。
- [ ] **Step 3: 验证编译**
```bash
cargo check -p erp-auth
```
- [ ] **Step 4: Commit**
```bash
git add crates/erp-auth/src/handler/auth_handler.rs crates/erp-auth/src/service/auth_service.rs
git commit -m "feat(auth): 登录接口支持 X-Tenant-ID 请求头租户解析"
```
---
### Task 8: 限流 fail-closed
**Files:**
- Modify: `crates/erp-server/src/middleware/rate_limit.rs:122-137`
- [ ] **Step 1: 将 Redis 不可达时的放行改为拒绝**
`apply_rate_limit` 函数中,将三处 `return next.run(req).await;` 改为返回 429
```rust
// 第一处Redis 不可达快速检查(约第 122-124 行)
if !avail.should_try().await {
tracing::warn!("Redis 不可达,启用 fail-closed 限流保护");
let body = RateLimitResponse {
error: "Too Many Requests".to_string(),
message: "服务暂时不可用,请稍后重试".to_string(),
};
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
}
// 第二处:连接失败(约第 135-137 行)
Err(e) => {
tracing::warn!(error = %e, "Redis 连接失败fail-closed 限流保护");
avail.mark_failed().await;
let body = RateLimitResponse {
error: "Too Many Requests".to_string(),
message: "服务暂时不可用,请稍后重试".to_string(),
};
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
}
// 第三处INCR 失败(约第 143-145 行)
Err(e) => {
tracing::warn!(error = %e, "Redis INCR 失败fail-closed 限流保护");
let body = RateLimitResponse {
error: "Too Many Requests".to_string(),
message: "服务暂时不可用,请稍后重试".to_string(),
};
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
}
```
注意:`RateLimitResponse` 已在模块级别定义(第 17-20 行),无需移动。使用 `(StatusCode, Json)` 元组模式与现有代码一致。
- [ ] **Step 2: 验证编译**
```bash
cargo check -p erp-server
```
- [ ] **Step 3: Commit**
```bash
git add crates/erp-server/src/middleware/rate_limit.rs
git commit -m "fix(server): 限流改为 fail-closed — Redis 不可达时拒绝请求"
```
---
## Chunk 3: 审计日志补全
### Task 9: 登录/登出/密码修改添加审计日志
**Files:**
- Modify: `crates/erp-auth/src/service/auth_service.rs`
- Reference: `crates/erp-core/src/audit.rs`
- [ ] **Step 1: 在 `login` 函数成功路径添加审计**
在登录成功后(签发 token 之后)添加:
```rust
// 审计日志:登录成功
// AuditLog::new 签名:(tenant_id: Uuid, user_id: Option<Uuid>, action: &str, resource_type: &str)
audit_service::record(
audit::AuditLog::new(user_model.tenant_id, Some(user_model.id), "user.login", "user"),
db,
).await;
```
- [ ] **Step 2: 在 `login` 函数失败路径添加审计**
失败审计需区分两种情况:
a) **用户不存在**`find_by_username` 返回 None— 此时无 `user_model`,使用 `Uuid::nil()` 作为 user_id
```rust
// 在 Ok(None) => return Err(AuthError::InvalidCredentials) 之前添加
audit_service::record(
audit::AuditLog::new(tenant_id, None, "user.login_failed", "user")
.with_resource_id("username", &req.username),
db,
).await;
```
b) **密码错误** — 此时已有 `user_model`
```rust
// 在密码验证失败返回 InvalidCredentials 之前添加
audit_service::record(
audit::AuditLog::new(tenant_id, Some(user_model.id), "user.login_failed", "user"),
db,
).await;
```
- [ ] **Step 3: 在 `logout` 函数添加审计**
- [ ] **Step 4: 在 `change_password` 函数添加审计**
- [ ] **Step 5: 验证编译**
```bash
cargo check -p erp-auth
```
- [ ] **Step 6: Commit**
```bash
git add crates/erp-auth/src/service/auth_service.rs
git commit -m "feat(auth): 登录/登出/密码修改添加审计日志"
```
---
### Task 10: 审计日志添加 IP 和 User-Agent
**Files:**
- Modify: `crates/erp-core/src/audit.rs`(确保 `with_request_info` 接受 IP + UA
- Modify: `crates/erp-auth/src/handler/auth_handler.rs`(从请求提取信息传入 service
- [ ] **Step 1: 确认 `audit.rs` 中 `with_request_info` 的签名**
确认 `AuditLogBuilder::with_request_info(ip: String, user_agent: String)` 存在且类型正确。如果不存在则添加。
- [ ] **Step 2: 在 auth handler 中提取 IP 和 UA 并传给 service**
**必须修改**以下函数签名(不仅仅是 `login`
- `AuthService::login` — 添加 `client_info: Option<ClientInfo>` 参数
- `AuthService::logout` — 同上
- `AuthService::change_password` — 同上
`auth_handler.rs` 中创建辅助函数提取请求信息:
```rust
struct ClientInfo {
ip: Option<String>,
user_agent: Option<String>,
}
fn extract_client_info(req: &Request) -> ClientInfo {
let ip = req.headers()
.get("X-Forwarded-For")
.or_else(|| req.headers().get("X-Real-IP"))
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string());
let user_agent = req.headers()
.get("user-agent")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
ClientInfo { ip, user_agent }
}
```
在每个 auth handler 函数中调用 `extract_client_info` 并传给 service。
- [ ] **Step 3: 在审计日志记录时调用 `.with_request_info(ip, user_agent)`**
- [ ] **Step 4: 验证编译**
```bash
cargo check -p erp-auth && cargo check -p erp-core
```
- [ ] **Step 5: Commit**
```bash
git add crates/erp-auth/ crates/erp-core/src/audit.rs
git commit -m "feat(audit): 审计日志添加 IP 地址和 User-Agent"
```
---
### Task 11: 关键实体 update 添加变更前后值
**Files:**
- Modify: `crates/erp-auth/src/service/user_service.rs``update` 函数)
- Modify: `crates/erp-auth/src/service/role_service.rs``update` 函数)
- [ ] **Step 1: 在 `user_service::update` 中,先查询旧值再更新**
在 update 函数中,获取旧模型后、执行更新前,记录:
```rust
let old_json = serde_json::to_value(&old_user)
.unwrap_or(serde_json::Value::Null);
// ... 执行更新 ...
let new_json = serde_json::to_value(&updated_user)
.unwrap_or(serde_json::Value::Null);
// AuditLog::new 签名:(tenant_id, user_id, action, resource_type)
// with_changes 签名:(Option<Value>, Option<Value>)
audit_service::record(
audit::AuditLog::new(tenant_id, Some(operator_id), "user.update", "user")
.with_resource_id("user_id", &old_user.id.to_string())
.with_changes(Some(old_json), Some(new_json)),
db,
).await;
```
- [ ] **Step 2: 同样修改 `role_service::update`**
- [ ] **Step 3: 确认 `with_changes` 方法签名**
实际签名为 `with_changes(mut self, old: Option<Value>, new: Option<Value>) -> Self`,已在 `audit.rs` 第 51-59 行定义。调用时用 `Some()` 包装值。
- [ ] **Step 4: 验证编译**
```bash
cargo check -p erp-auth
```
- [ ] **Step 5: Commit**
```bash
git add crates/erp-auth/ crates/erp-core/src/audit.rs
git commit -m "feat(audit): 用户/角色更新记录变更前后值"
```
---
### Task 12: 插件 CRUD 添加审计日志
**Files:**
- Modify: `crates/erp-plugin/src/data_service.rs`
- [ ] **Step 1: 添加审计日志 import 和调用**
首先在 `data_service.rs` 顶部添加 import
```rust
use erp_core::{audit, audit_service};
```
然后在 `create_record``update_record`(含 `partial_update`)、`delete_record` 中添加审计日志。审计调用需要 `tenant_id``operator_id`
- `tenant_id` 从函数参数获取
- `operator_id` 从函数参数获取(若函数缺少此参数则需补充)
示例:
```rust
// create_record 审计
audit_service::record(
audit::AuditLog::new(tenant_id, Some(operator_id), "plugin.data.create", entity_name),
db,
).await;
```
- [ ] **Step 2: 验证编译**
```bash
cargo check -p erp-plugin
```
- [ ] **Step 3: Commit**
```bash
git add crates/erp-plugin/src/data_service.rs
git commit -m "feat(plugin): 数据 CRUD 操作添加审计日志"
```
---
## Chunk 4: CI/CD + Docker 生产化
### Task 13: 创建 Gitea Actions CI/CD 流水线
**Files:**
- Create: `.gitea/workflows/ci.yml`
- [ ] **Step 1: 创建工作流目录**
```bash
mkdir -p .gitea/workflows
```
- [ ] **Step 2: 创建 CI 流水线文件**
创建 `.gitea/workflows/ci.yml`
```yaml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
rust-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- run: cargo fmt --check --all
- run: cargo clippy -- -D warnings
rust-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: erp_test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: ". -> target"
- run: cargo test --workspace
env:
ERP__DATABASE__URL: postgres://test:test@localhost:5432/erp_test
ERP__JWT__SECRET: ci-test-secret
ERP__AUTH__SUPER_ADMIN_PASSWORD: CI_Test_Pass_2026
frontend-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: cd apps/web && corepack enable && pnpm install --frozen-lockfile
- run: cd apps/web && pnpm build
security-audit:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo install cargo-audit && cargo audit
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: cd apps/web && corepack enable && pnpm install --frozen-lockfile && pnpm audit
```
- [ ] **Step 3: Commit**
```bash
git add .gitea/
git commit -m "ci: 添加 Gitea Actions CI/CD 流水线"
```
---
### Task 14: Docker 生产化
**Files:**
- Modify: `docker/docker-compose.yml`
- [ ] **Step 1: 移除端口暴露,添加 Redis 密码和资源限制**
将 PostgreSQL 的 `ports: "5432:5432"` 改为 `expose: ["5432"]`(仅容器网络内部可访问)。
将 Redis 的 `ports: "6379:6379"` 改为 `expose: ["6379"]`,并添加命令 `--requirepass ${REDIS_PASSWORD:-erp_redis_dev}`
为两个服务添加资源限制:
```yaml
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
```
- [ ] **Step 2: 创建开发用 `docker-compose.override.yml`**
由于生产配置移除了端口暴露,本地开发需要 override 文件恢复端口访问:
```yaml
# docker/docker-compose.override.yml — 本地开发用,不提交到仓库
services:
postgres:
ports:
- "5432:5432"
redis:
ports:
- "6379:6379"
```
`docker-compose.override.yml` 添加到 `.gitignore`。Docker Compose 会自动合并 `docker-compose.yml``docker-compose.override.yml`
- [ ] **Step 3: 更新 `.env.example`**
添加 `REDIS_PASSWORD` 变量说明。
- [ ] **Step 3: 更新 `default.toml` 的 Redis URL 格式**
如果 Redis 需要密码URL 格式改为 `redis://:password@localhost:6379`
- [ ] **Step 4: 验证 Docker Compose 配置有效**
```bash
cd docker && docker compose config
```
Expected: 无语法错误
- [ ] **Step 5: Commit**
```bash
git add docker/
git commit -m "fix(docker): 生产化配置 — 端口不暴露、Redis 密码、资源限制"
```
---
## 验证清单
完成所有 Task 后,执行以下验证:
- [ ] **V1: 默认配置拒绝启动**
```bash
cargo run -p erp-server
```
Expected: 进程退出,日志包含 "JWT 密钥为默认值,拒绝启动"
- [ ] **V2: 环境变量设置后正常启动**
```bash
ERP__JWT__SECRET="test-secret" ERP__DATABASE__URL="postgres://erp:erp_dev_2024@localhost:5432/erp" ERP__AUTH__SUPER_ADMIN_PASSWORD="TestPass123" cargo run -p erp-server
```
Expected: 服务正常启动
- [ ] **V3: 全量编译和测试**
```bash
cargo check && cargo test --workspace
```
Expected: 全部通过
- [ ] **V4: 前端构建**
```bash
cd apps/web && pnpm build
```
Expected: 构建成功
- [ ] **V5: Docker Compose 正常启动**
```bash
cd docker && docker compose up -d && docker compose ps
```
Expected: PostgreSQL 和 Redis 状态 healthy
- [ ] **V6: Push 到远程仓库**
```bash
git push origin main
```
Expected: Gitea Actions 触发 CI 流水线