303 lines
9.7 KiB
Rust
Raw Normal View History

//! 下载中心 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 — 获取下载链接
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
///
/// **归属**:导出任务按 `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 \
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
FROM download_tasks WHERE id = ? AND user_id = ?"
)
.bind(id)
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
.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 — 清理过期文件
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
///
/// **范围(本次修复)**:导出任务是**按用户私有**的数据,清理也必须按 `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")?;
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
// 工厂:全量清理;客户侧:仅本人(`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)"
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
};
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)
})))
}