12451 61adcdeaf9 fix(security)+fix(api)+docs: 越权面修复、登录门控与状态语义对齐、文档全面校对
- 越权面(全部 before/after 实测可复现,10 条):cabinets/filter 的 project_id/organization_id
  参数分支越权(客户可拿对方向柜全字段)、organization-tree 返回全量组织树、
  cabin-boards/compartments/cabinets 三个 options 无归属校验、batteries 无归属(改为经
  charge_orders→cabinets→projects 间接归属)、feedback 跨组织读且能改他人状态、
  GET /downloads/{id} 漏 user_id 过滤(跨用户元数据泄露)、
  POST /downloads/cleanup 全局删除所有组织导出(改客户侧只清自己)、
  /api/nodes/* 仅需登录(客户可读内网 IP 并注销节点)→ 收紧 role_level>=2;
  /api/devices/stats 同样收紧;新增共享闸门 auth::require_super_admin
- TCP 登录门控:下行投递改为"登录前白名单"(PRE_LOGIN_ALLOWED_ACTS),与上行同一规则
- 60 秒超时对齐协议:挂起项 TTL 120→60 + 值内嵌 deadline 精确判窗 + 超时 WARN(兼容老格式)
- 下发前在线判据改连接级认证标记 device:authed:{imei}(消除"管理端显示成功、设备收不到")
  ⚠️ device-server 与 api-server 必须同批发布
- 协议对齐:auth_str 登记校验提到限流之前、build_spec Topology 分支 clamp(15/8)、
  行号引用改小节引用、修 3 处错引小节
- 状态语义:compartments.status 只表占用,充满/故障由通道状态派生;柜级 status1/status2
  与开门按钮(柜级独立动作不联动);列注释对齐(cabinets.status 补 2=禁用、
  charge_orders.status 标注 2 保留未使用)
- 假绿单测修复:超时 SQL 抽常量 + 纯函数孪生体双向断言(方向写反必红)
- RSSI 两端统一为同源纯函数 5 档(小程序显示随之变化)+ test/rssi_signal.test.mjs
- nginx:扫码入口 abstract 限流(zone=scan_abstract_zone 30r/m burst=20)+ 主配置仓库镜像
- 文档:AGENTS.md 新增「踩过的坑」7 条教训 + 部署注意事项 11 条 + 文档口径优先级;
  docs/API.md 接口总表 80 条与 mod.rs 逐条一致、Redis 契约按代码重写、9 处事实错误修正;
  权威协议文档 v1.1.1(协议行为未变,仅修订文档自身的错误/矛盾/缺失)
2026-09-20 03:23:00 +08:00

303 lines
9.7 KiB
Rust
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.

//! 下载中心 API — 任务列表、下载链接、文件下载、过期清理
use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::http::header;
use axum::response::Response;
use axum::Json;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
/// 下载任务列表查询参数
#[derive(Debug, Deserialize)]
pub struct DownloadListQuery {
#[serde(default = "default_page")]
pub page: i64,
#[serde(default = "default_page_size")]
pub page_size: i64,
}
/// 清理请求参数
#[derive(Debug, Deserialize)]
pub struct CleanupRequest {
/// 保留天数,默认7天
#[serde(default = "default_retain_days")]
pub retain_days: i32,
}
/// 下载任务列表项
#[derive(Debug, Serialize)]
pub struct DownloadTaskItem {
pub id: i64,
pub task_type: String,
pub status: i8,
pub status_text: String,
pub file_name: Option<String>,
pub progress: i32,
pub error_message: Option<String>,
pub created_at: Option<String>,
pub completed_at: Option<String>,
}
fn default_page() -> i64 { 1 }
fn default_page_size() -> i64 { 20 }
fn default_retain_days() -> i32 { 7 }
/// 状态码转文本
fn download_status_text(status: i8) -> &'static str {
match status {
0 => "排队中",
1 => "处理中",
2 => "已完成",
3 => "失败",
_ => "未知",
}
}
/// 数据库行映射
#[derive(Debug, sqlx::FromRow)]
struct DownloadTaskRow {
id: i64,
task_type: String,
status: i8,
file_name: Option<String>,
file_path: Option<String>,
progress: i32,
error_message: Option<String>,
created_at: Option<NaiveDateTime>,
completed_at: Option<NaiveDateTime>,
}
/// 将数据库行转为响应对象
fn row_to_item(row: DownloadTaskRow) -> DownloadTaskItem {
DownloadTaskItem {
id: row.id,
task_type: row.task_type,
status: row.status,
status_text: download_status_text(row.status).to_string(),
file_name: row.file_name,
progress: row.progress,
error_message: if row.status == 3 { row.error_message } else { None },
created_at: row.created_at.map(|t| t.to_string()),
completed_at: row.completed_at.map(|t| t.to_string()),
}
}
/// GET /api/downloads — 下载任务列表
pub async fn list_downloads(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<DownloadListQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
let user_id = user.user_id;
let offset = (params.page - 1) * params.page_size;
// 查询总数
let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM download_tasks WHERE user_id = ?"
)
.bind(user_id)
.fetch_one(&state.mysql)
.await?;
// 查询列表
let rows = sqlx::query_as::<_, DownloadTaskRow>(
"SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \
FROM download_tasks WHERE user_id = ? \
ORDER BY created_at DESC LIMIT ? OFFSET ?"
)
.bind(user_id)
.bind(params.page_size)
.bind(offset)
.fetch_all(&state.mysql)
.await?;
let list: Vec<DownloadTaskItem> = rows.into_iter().map(row_to_item).collect();
Ok(Json(json!({
"code": 0,
"data": {
"list": list,
"total": total,
"page": params.page,
"page_size": params.page_size,
},
"message": "ok"
})))
}
/// GET /api/downloads/:id — 获取下载链接
///
/// **归属**:导出任务按 `user_id` **天然私有**(与 [`list_downloads`] / [`download_file`] 同口径)。
/// 历史缺陷:本接口原先只按 `id` 查询,任何持 `charge_record:export` 的客户账号
/// 传别人的任务 id 就能拿到**别人的导出任务信息**(文件名、任务是否存在),
/// 并可继续调 `/file`(`/file` 有 `user_id` 过滤 ⇒ 拿到 404,但元数据已泄露)。
/// 现在统一加 `AND user_id = ?`:别人的任务一律 `NotFound`(与 `/file` 的 404 表现一致)。
pub async fn get_download(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
let row: Option<DownloadTaskRow> = sqlx::query_as(
"SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \
FROM download_tasks WHERE id = ? AND user_id = ?"
)
.bind(id)
.bind(user.user_id)
.fetch_optional(&state.mysql)
.await?;
let row = row.ok_or_else(|| AppError::NotFound("下载任务不存在".into()))?;
if row.status != 2 {
return Err(AppError::BadRequest("任务尚未完成".into()));
}
let file_path = row.file_path.unwrap_or_default();
// 检查文件是否存在
if !std::path::Path::new(&file_path).exists() {
return Err(AppError::NotFound("文件不存在或已清理".into()));
}
// 返回文件下载路径(后续可改为签名URL)
let download_url = format!("/api/downloads/{}/file", id);
Ok(Json(json!({
"code": 0,
"data": {
"id": row.id,
"file_name": row.file_name,
"download_url": download_url,
},
"message": "ok"
})))
}
/// GET /api/downloads/:id/file — 下载文件(二进制流)
pub async fn download_file(
user: CurrentUser,
State(state): State<super::AppState>,
Path(id): Path<i64>,
) -> Result<Response<Body>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
let db = &state.mysql;
// 查询下载任务(限定本人)
let task: Option<DownloadTaskRow> = sqlx::query_as(
"SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \
FROM download_tasks WHERE id = ? AND user_id = ?",
)
.bind(id)
.bind(user.user_id)
.fetch_optional(db)
.await?;
let task = task.ok_or_else(|| AppError::NotFound("下载任务不存在".into()))?;
if task.status != 2 {
return Err(AppError::BadRequest("文件未生成完成".into()));
}
let file_path = task
.file_path
.ok_or_else(|| AppError::NotFound("文件路径不存在".into()))?;
// 读取文件内容
let file_content = tokio::fs::read(&file_path).await.map_err(|e| {
tracing::error!("读取下载文件失败 path={} err={}", file_path, e);
AppError::Internal(format!("读取文件失败: {}", e))
})?;
// 推断文件名
let file_name = task.file_name.unwrap_or_else(|| "download.xlsx".to_string());
// 构建文件下载响应
let response = Response::builder()
.status(200)
.header(
header::CONTENT_TYPE,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", file_name),
)
.body(Body::from(file_content))
.map_err(|e| AppError::Internal(format!("构建响应失败: {}", e)))?;
Ok(response)
}
/// POST /api/downloads/cleanup — 清理过期文件
///
/// **范围(本次修复)**:导出任务是**按用户私有**的数据,清理也必须按 `user_id` 限定 ——
/// 历史缺陷:原先不分用户、直接删除**全平台**已完成的过期任务文件,
/// 任何持 `charge_record:export` 的客户账号都能删掉别人的导出文件(破坏性操作)。
/// 现在:工厂(`role_level >= 2`)保留全量清理(运维需要),客户侧**只清理自己的任务**,
/// 别人的任务与文件一概不动。
pub async fn cleanup_downloads(
user: CurrentUser,
State(state): State<super::AppState>,
Json(req): Json<CleanupRequest>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "charge_record:export")?;
// 工厂:全量清理;客户侧:仅本人(`AND user_id = ?` 追加在保留天数条件之后)
let only_own = user.role_level < auth::SUPER_ADMIN_ROLE_LEVEL;
let sql = if only_own {
"SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \
FROM download_tasks \
WHERE status = 2 AND completed_at < DATE_SUB(NOW(), INTERVAL ? DAY) AND user_id = ?"
} else {
"SELECT id, task_type, status, file_name, file_path, progress, \
error_message, created_at, completed_at \
FROM download_tasks \
WHERE status = 2 AND completed_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
};
let mut expired_query = sqlx::query_as::<_, DownloadTaskRow>(sql).bind(req.retain_days);
if only_own {
expired_query = expired_query.bind(user.user_id);
}
let expired: Vec<DownloadTaskRow> = expired_query.fetch_all(&state.mysql).await?;
let mut cleaned = 0u32;
for task in &expired {
// 删除物理文件
if let Some(ref path) = task.file_path {
let _ = tokio::fs::remove_file(path).await;
}
// 更新数据库记录
let _ = sqlx::query(
"UPDATE download_tasks SET file_path = NULL, status = 3, \
error_message = '文件已过期清理' WHERE id = ?"
)
.bind(task.id)
.execute(&state.mysql)
.await;
cleaned += 1;
}
tracing::info!("清理过期下载文件 {} 个", cleaned);
Ok(Json(json!({
"code": 0,
"data": { "cleaned": cleaned },
"message": format!("已清理 {} 个过期文件", cleaned)
})))
}