- 越权面(全部 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(协议行为未变,仅修订文档自身的错误/矛盾/缺失)
303 lines
9.7 KiB
Rust
303 lines
9.7 KiB
Rust
//! 下载中心 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)
|
||
})))
|
||
}
|