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
This commit is contained in:
@@ -107,11 +107,12 @@ super_admin_password = "__MUST_SET_VIA_ENV__"
|
||||
|
||||
在项目根目录创建 `.env.development`(已被 `.gitignore` 中 `*.env.local` 覆盖,但需显式添加 `.env.development`):
|
||||
|
||||
```bash
|
||||
```
|
||||
# .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__AUTH__SUPER_ADMIN_PASSWORD=Admin@2026
|
||||
ERP__SUPER_ADMIN_PASSWORD=Admin@2026
|
||||
```
|
||||
|
||||
更新 `.gitignore`,添加 `.env.development`。
|
||||
@@ -239,14 +240,12 @@ impl From<AppError> for AuthError {
|
||||
|
||||
- [ ] **Step 2: 修复所有依赖此反向映射的调用点**
|
||||
|
||||
搜索 `AuthError` 从 `AppError` 隐式转换的位置:
|
||||
反向映射主要用于 `on_tenant_created` / `on_tenant_deleted` 中。检查这两个函数 — 它们已经返回 `AppResult<()>`(不是 `AuthResult`),所以不会直接受影响。
|
||||
|
||||
```bash
|
||||
grep -rn "map_err.*AppError" crates/erp-auth/src/
|
||||
grep -rn "?.*AuthError" crates/erp-auth/src/
|
||||
```
|
||||
|
||||
将 `on_tenant_deleted` 等函数中的 `AppError` → `AuthError` 转换改为直接返回 `AppError`(函数签名可能需从 `AuthResult<T>` 改为 `AppResult<T>`,或在调用点显式 `.map_err()`)。
|
||||
真正受影响的是 `auth_service.rs` 中可能从其他 crate 传入 `AppError` 并隐式转为 `AuthError` 的路径。逐一检查:
|
||||
- `auth_service.rs` — 所有 `.map_err()` 调用是否仍能编译
|
||||
- `user_service.rs` — 同上
|
||||
- 如果有编译错误,在调用点使用显式 `.map_err(|e| AuthError::Validation(e.to_string()))` 而非依赖隐式转换
|
||||
|
||||
- [ ] **Step 3: 删除反向映射的测试**
|
||||
|
||||
@@ -296,6 +295,7 @@ let user_model = user::Entity::find_by_id(claims.sub)
|
||||
.ok_or(AuthError::TokenRevoked)?;
|
||||
|
||||
// 验证用户属于 JWT 中声明的租户
|
||||
// 注意:JWT claims 中租户 ID 字段名为 `tid`(与 TokenService 签发时一致)
|
||||
if user_model.tenant_id != claims.tid {
|
||||
tracing::warn!(
|
||||
user_id = %claims.sub,
|
||||
@@ -325,7 +325,7 @@ 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` 三个函数)
|
||||
- Modify: `crates/erp-auth/src/service/user_service.rs`(`get_by_id`、`update`、`delete`、`assign_roles` 四个函数)
|
||||
|
||||
- [ ] **Step 1: 修改 `get_by_id`(约第 129-134 行)**
|
||||
|
||||
@@ -344,9 +344,9 @@ pub async fn get_by_id(id: Uuid, tenant_id: Uuid, db: &DatabaseConnection) -> Au
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 同样修改 `update` 和 `delete` 函数**
|
||||
- [ ] **Step 2: 同样修改 `update`、`delete` 和 `assign_roles` 函数**
|
||||
|
||||
将这两个函数中的 `find_by_id` + 内存 `.filter()` 改为相同的 DB 级过滤模式。
|
||||
将这三个函数中的 `find_by_id` + 内存 `.filter()` 改为相同的 DB 级过滤模式。注意:`login`、`list` 函数已正确使用数据库级过滤,无需修改。
|
||||
|
||||
- [ ] **Step 3: 验证编译和测试**
|
||||
|
||||
@@ -363,6 +363,45 @@ 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:**
|
||||
@@ -376,33 +415,36 @@ git commit -m "fix(auth): get_by_id/update/delete 改为数据库级 tenant_id
|
||||
// 第一处:Redis 不可达快速检查(约第 122-124 行)
|
||||
if !avail.should_try().await {
|
||||
tracing::warn!("Redis 不可达,启用 fail-closed 限流保护");
|
||||
return RateLimitResponse {
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "服务暂时不可用,请稍后重试".to_string(),
|
||||
}.into_response();
|
||||
};
|
||||
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;
|
||||
return RateLimitResponse {
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "服务暂时不可用,请稍后重试".to_string(),
|
||||
}.into_response();
|
||||
};
|
||||
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
}
|
||||
|
||||
// 第三处:INCR 失败(约第 143-145 行)
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Redis INCR 失败,fail-closed 限流保护");
|
||||
return RateLimitResponse {
|
||||
let body = RateLimitResponse {
|
||||
error: "Too Many Requests".to_string(),
|
||||
message: "服务暂时不可用,请稍后重试".to_string(),
|
||||
}.into_response();
|
||||
};
|
||||
return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
}
|
||||
```
|
||||
|
||||
注意:需将 `RateLimitResponse` 结构体移到函数外部或使其可访问。
|
||||
注意:`RateLimitResponse` 已在模块级别定义(第 17-20 行),无需移动。使用 `(StatusCode, Json)` 元组模式与现有代码一致。
|
||||
|
||||
- [ ] **Step 2: 验证编译**
|
||||
|
||||
@@ -433,23 +475,32 @@ git commit -m "fix(server): 限流改为 fail-closed — Redis 不可达时拒
|
||||
|
||||
```rust
|
||||
// 审计日志:登录成功
|
||||
// AuditLog::new 签名:(tenant_id: Uuid, user_id: Option<Uuid>, action: &str, resource_type: &str)
|
||||
audit_service::record(
|
||||
audit::AuditLog::new("user.login", user_model.tenant_id, user_model.id)
|
||||
.with_detail("username", &req.username),
|
||||
audit::AuditLog::new(user_model.tenant_id, Some(user_model.id), "user.login", "user"),
|
||||
db,
|
||||
).await;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 `login` 函数失败路径(密码错误/用户禁用)添加审计**
|
||||
- [ ] **Step 2: 在 `login` 函数失败路径添加审计**
|
||||
|
||||
在返回 `InvalidCredentials` / `UserDisabled` 之前添加审计日志(不含敏感信息):
|
||||
失败审计需区分两种情况:
|
||||
|
||||
a) **用户不存在**(`find_by_username` 返回 None)— 此时无 `user_model`,使用 `Uuid::nil()` 作为 user_id:
|
||||
```rust
|
||||
// 审计日志:登录失败
|
||||
// 在 Ok(None) => return Err(AuthError::InvalidCredentials) 之前添加
|
||||
audit_service::record(
|
||||
audit::AuditLog::new("user.login_failed", tenant_id, Uuid::nil())
|
||||
.with_detail("username", &req.username)
|
||||
.with_detail("reason", "invalid_credentials"),
|
||||
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;
|
||||
```
|
||||
@@ -485,9 +536,35 @@ git commit -m "feat(auth): 登录/登出/密码修改添加审计日志"
|
||||
|
||||
- [ ] **Step 2: 在 auth handler 中提取 IP 和 UA 并传给 service**
|
||||
|
||||
在 `login` handler 中从 `ConnectInfo` / `X-Forwarded-For` / `X-Real-IP` 提取 IP,从 `User-Agent` header 提取 UA,传给 `AuthService::login`。
|
||||
**必须修改**以下函数签名(不仅仅是 `login`):
|
||||
|
||||
这可能需要扩展 `login` 函数签名,添加 `ip: Option<String>` 和 `user_agent: Option<String>` 参数。
|
||||
- `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)`**
|
||||
|
||||
@@ -523,25 +600,21 @@ let old_json = serde_json::to_value(&old_user)
|
||||
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("user.update", tenant_id, operator_id)
|
||||
.with_changes(old_json, new_json),
|
||||
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` 方法存在于 `audit.rs`**
|
||||
- [ ] **Step 3: 确认 `with_changes` 方法签名**
|
||||
|
||||
如果不存在则添加:
|
||||
```rust
|
||||
pub fn with_changes(mut self, old: serde_json::Value, new: serde_json::Value) -> Self {
|
||||
self.old_value = Some(old);
|
||||
self.new_value = Some(new);
|
||||
self
|
||||
}
|
||||
```
|
||||
实际签名为 `with_changes(mut self, old: Option<Value>, new: Option<Value>) -> Self`,已在 `audit.rs` 第 51-59 行定义。调用时用 `Some()` 包装值。
|
||||
|
||||
- [ ] **Step 4: 验证编译**
|
||||
|
||||
@@ -563,9 +636,25 @@ git commit -m "feat(audit): 用户/角色更新记录变更前后值"
|
||||
**Files:**
|
||||
- Modify: `crates/erp-plugin/src/data_service.rs`
|
||||
|
||||
- [ ] **Step 1: 在 `create_record`、`update_record`、`delete_record` 中添加审计**
|
||||
- [ ] **Step 1: 添加审计日志 import 和调用**
|
||||
|
||||
每个 CUD 操作添加审计日志记录,包含 `plugin_id`、`entity_name`、`record_id`。
|
||||
首先在 `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: 验证编译**
|
||||
|
||||
@@ -699,7 +788,24 @@ deploy:
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新 `.env.example`**
|
||||
- [ ] **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` 变量说明。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user