MarkBase架构升级:Multi-Volume Virtual Tree + Dual-View Management + Git Remote修正
核心功能: - ✅ Categories/Series双视图管理(category_view.rs + import_markdown.rs) - ✅ FUSE Multi-Volume支持(tree_type参数) - ✅ SSH/SFTP/SCP/rsync协议完整实现(4042行) - ✅ NFS/SMB Module Phase 1-3完成 - ✅ Archive Module Phase 1-4完成(2916行) - ✅ Download Center API完整实现 - ✅ S3兼容API实现(560行) Git配置修正: - ✅ 删除错误origin(gitea.momentry.ddns.net) - ✅ 删除m5max128(指向机器名) - ✅ 设置origin = m5max128gitea.momentry.ddns.net/admin/markbase - ✅ 设置m4minigitea = m4minigitea.momentry.ddns.net/warren/markbase 数据清理: - ✅ 删除38个临时SQLite(保留accusys.sqlite、demo.sqlite) - ✅ 删除.bak、test_*.bin、调试脚本等临时文件 - ✅ 删除临时目录(build/、download files/、raid_test/等) - ✅ 更新.gitignore排除临时文件 架构优化: - 52个文件修改,2434行新增,4739行删除 - Workspace成员整合(16个crate) - 数据库状态:accusys.sqlite保留(主demo测试) 远程同步: - ✅ 准备推送到m5max128gitea(远程Gitea) - ✅ 准备推送到m4minigitea(本地Gitea)
This commit is contained in:
246
markbase-core/src/download/db.rs
Normal file
246
markbase-core/src/download/db.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use anyhow::Result;
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Product {
|
||||
pub id: i64,
|
||||
pub product_name: String,
|
||||
pub series: String,
|
||||
pub description: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProductFile {
|
||||
pub id: i64,
|
||||
pub product_id: i64,
|
||||
pub file_path: String,
|
||||
pub file_name: String,
|
||||
pub file_size: u64,
|
||||
pub file_hash: Option<String>,
|
||||
pub download_count: i64,
|
||||
pub uploaded_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SeriesStats {
|
||||
pub series: String,
|
||||
pub product_count: i64,
|
||||
pub file_count: i64,
|
||||
pub total_size: u64,
|
||||
}
|
||||
|
||||
pub struct DownloadDb {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl DownloadDb {
|
||||
pub fn new(db_path: &str) -> Result<Self> {
|
||||
let path = Path::new(db_path);
|
||||
let conn = if path.exists() {
|
||||
Connection::open(db_path)?
|
||||
} else {
|
||||
let conn = Connection::open(db_path)?;
|
||||
Self::init_tables(&conn)?;
|
||||
conn
|
||||
};
|
||||
|
||||
Ok(DownloadDb { conn })
|
||||
}
|
||||
|
||||
fn init_tables(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_name TEXT NOT NULL UNIQUE,
|
||||
series TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_id INTEGER NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
file_hash TEXT,
|
||||
download_count INTEGER NOT NULL DEFAULT 0,
|
||||
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (product_id) REFERENCES products(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_product_files_product_id ON product_files(product_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_series ON products(series);
|
||||
"
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_product(&mut self, product_name: &str, series: &str, description: Option<&str>) -> Result<i64> {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO products (product_name, series, description, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![product_name, series, description, now],
|
||||
)?;
|
||||
|
||||
Ok(self.conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn get_all_products(&self) -> Result<Vec<Product>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, product_name, series, description, created_at FROM products ORDER BY series, product_name"
|
||||
)?;
|
||||
|
||||
let products = stmt.query_map([], |row| {
|
||||
Ok(Product {
|
||||
id: row.get(0)?,
|
||||
product_name: row.get(1)?,
|
||||
series: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(products)
|
||||
}
|
||||
|
||||
pub fn get_products_by_series(&self, series: &str) -> Result<Vec<Product>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, product_name, series, description, created_at FROM products
|
||||
WHERE series = ?1 ORDER BY product_name"
|
||||
)?;
|
||||
|
||||
let products = stmt.query_map([series], |row| {
|
||||
Ok(Product {
|
||||
id: row.get(0)?,
|
||||
product_name: row.get(1)?,
|
||||
series: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(products)
|
||||
}
|
||||
|
||||
pub fn get_series_stats(&self) -> Result<Vec<SeriesStats>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT
|
||||
p.series,
|
||||
COUNT(DISTINCT p.id) as product_count,
|
||||
COUNT(pf.id) as file_count,
|
||||
COALESCE(SUM(pf.file_size), 0) as total_size
|
||||
FROM products p
|
||||
LEFT JOIN product_files pf ON p.id = pf.product_id
|
||||
GROUP BY p.series
|
||||
ORDER BY p.series"
|
||||
)?;
|
||||
|
||||
let stats = stmt.query_map([], |row| {
|
||||
Ok(SeriesStats {
|
||||
series: row.get(0)?,
|
||||
product_count: row.get(1)?,
|
||||
file_count: row.get(2)?,
|
||||
total_size: row.get::<_, i64>(3)? as u64,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
pub fn add_file_to_product(&mut self, product_id: i64, file_path: &str, file_name: &str, file_size: u64, file_hash: Option<&str>) -> Result<i64> {
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO product_files (product_id, file_path, file_name, file_size, file_hash, uploaded_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![product_id, file_path, file_name, file_size as i64, file_hash, now],
|
||||
)?;
|
||||
|
||||
Ok(self.conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn get_files_by_product(&self, product_id: i64) -> Result<Vec<ProductFile>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, product_id, file_path, file_name, file_size, file_hash, download_count, uploaded_at
|
||||
FROM product_files WHERE product_id = ?1 ORDER BY file_name"
|
||||
)?;
|
||||
|
||||
let files = stmt.query_map([product_id], |row| {
|
||||
Ok(ProductFile {
|
||||
id: row.get(0)?,
|
||||
product_id: row.get(1)?,
|
||||
file_path: row.get(2)?,
|
||||
file_name: row.get(3)?,
|
||||
file_size: row.get::<_, i64>(4)? as u64,
|
||||
file_hash: row.get(5)?,
|
||||
download_count: row.get(6)?,
|
||||
uploaded_at: row.get(7)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub fn increment_download_count(&mut self, file_id: i64) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE product_files SET download_count = download_count + 1 WHERE id = ?1",
|
||||
params![file_id],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_all_files(&self) -> Result<Vec<ProductFile>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, product_id, file_path, file_name, file_size, file_hash, download_count, uploaded_at
|
||||
FROM product_files ORDER BY uploaded_at DESC"
|
||||
)?;
|
||||
|
||||
let files = stmt.query_map([], |row| {
|
||||
Ok(ProductFile {
|
||||
id: row.get(0)?,
|
||||
product_id: row.get(1)?,
|
||||
file_path: row.get(2)?,
|
||||
file_name: row.get(3)?,
|
||||
file_size: row.get::<_, i64>(4)? as u64,
|
||||
file_hash: row.get(5)?,
|
||||
download_count: row.get(6)?,
|
||||
uploaded_at: row.get(7)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub fn delete_product_with_files(&mut self, product_id: i64) -> Result<(i64, i64)> {
|
||||
// 先删除关联的文件映射
|
||||
self.conn.execute(
|
||||
"DELETE FROM product_files WHERE product_id = ?1",
|
||||
params![product_id],
|
||||
)?;
|
||||
|
||||
let deleted_files = self.conn.last_insert_rowid();
|
||||
|
||||
// 再删除产品记录
|
||||
self.conn.execute(
|
||||
"DELETE FROM products WHERE id = ?1",
|
||||
params![product_id],
|
||||
)?;
|
||||
|
||||
let deleted_product = if self.conn.last_insert_rowid() > 0 { 1 } else { 0 };
|
||||
|
||||
Ok((deleted_files, deleted_product))
|
||||
}
|
||||
}
|
||||
185
markbase-core/src/download/download_handler.rs
Normal file
185
markbase-core/src/download/download_handler.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
|
||||
use crate::server::AppState;
|
||||
use crate::download::db::DownloadDb;
|
||||
|
||||
pub async fn download_file(
|
||||
Path(file_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
|
||||
|
||||
match DownloadDb::new(&db_path) {
|
||||
Ok(mut db) => {
|
||||
// 获取文件信息
|
||||
match db.get_files_by_product(file_id) {
|
||||
Ok(files) => {
|
||||
if files.is_empty() {
|
||||
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
||||
}
|
||||
|
||||
let file_info = &files[0];
|
||||
|
||||
// 更新下载统计
|
||||
db.increment_download_count(file_info.id).ok();
|
||||
|
||||
// 构建文件路径(使用配置的db_dir)
|
||||
let base_path = state.db_dir.replace("users", "Downloads");
|
||||
let file_path = std::path::Path::new(&base_path).join(&file_info.file_path);
|
||||
|
||||
if !file_path.exists() {
|
||||
return (StatusCode::NOT_FOUND, "File not found on disk").into_response();
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
match File::open(&file_path) {
|
||||
Ok(mut file) => {
|
||||
let mut buffer = Vec::new();
|
||||
match file.read_to_end(&mut buffer) {
|
||||
Ok(_) => {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", file_info.file_name)
|
||||
)
|
||||
.header("X-File-Hash", file_info.file_hash.clone().unwrap_or_default())
|
||||
.header("X-File-Size", file_info.file_size)
|
||||
.body(buffer.into())
|
||||
.unwrap()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error reading file: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error opening file: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_file_by_path(
|
||||
Path((user_id, file_path)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
// Support both user directories and product directories
|
||||
let base_path: String = if user_id == "products" {
|
||||
// Product files are in data/downloads/ (directly, not in products subfolder)
|
||||
"/Users/accusys/markbase/data/downloads".to_string()
|
||||
} else {
|
||||
// User files are in Downloads/user_id/
|
||||
format!("/Users/accusys/Downloads/{}", user_id)
|
||||
};
|
||||
|
||||
let full_path = std::path::Path::new(&base_path).join(&file_path);
|
||||
|
||||
if !full_path.exists() {
|
||||
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
||||
}
|
||||
|
||||
let filename = file_path.split('/').last().unwrap_or("unknown");
|
||||
|
||||
match File::open(&full_path) {
|
||||
Ok(mut file) => {
|
||||
let mut buffer = Vec::new();
|
||||
match file.read_to_end(&mut buffer) {
|
||||
Ok(_) => {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename)
|
||||
)
|
||||
.body(buffer.into())
|
||||
.unwrap()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error reading file: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error opening file: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_download_stats(
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
|
||||
|
||||
match DownloadDb::new(&db_path) {
|
||||
Ok(db) => {
|
||||
match db.get_all_files() {
|
||||
Ok(files) => {
|
||||
let total_downloads: i64 = files.iter().map(|f| f.download_count).sum();
|
||||
let top_files: Vec<_> = files.iter()
|
||||
.filter(|f| f.download_count > 0)
|
||||
.take(10)
|
||||
.map(|f| serde_json::json!({
|
||||
"file_name": f.file_name,
|
||||
"download_count": f.download_count
|
||||
}))
|
||||
.collect();
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"total_files": files.len(),
|
||||
"total_downloads": total_downloads,
|
||||
"top_files": top_files
|
||||
}))
|
||||
)
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e.to_string()})))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_product_file(
|
||||
Path((product_series, file_path)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let base_path = format!("/Users/accusys/markbase/data/downloads/{}/", product_series);
|
||||
let full_path = std::path::Path::new(&base_path).join(&file_path);
|
||||
|
||||
if !full_path.exists() {
|
||||
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
||||
}
|
||||
|
||||
if full_path.is_dir() {
|
||||
return (StatusCode::BAD_REQUEST, "Path is a directory, not a file").into_response();
|
||||
}
|
||||
|
||||
let filename = file_path.split('/').last().unwrap_or("unknown");
|
||||
|
||||
match File::open(&full_path) {
|
||||
Ok(mut file) => {
|
||||
let mut buffer = Vec::new();
|
||||
match file.read_to_end(&mut buffer) {
|
||||
Ok(_) => {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(
|
||||
header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename)
|
||||
)
|
||||
.body(buffer.into())
|
||||
.unwrap()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error reading file: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error opening file: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
52
markbase-core/src/download/handlers.rs
Normal file
52
markbase-core/src/download/handlers.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Html, IntoResponse, Json},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::server::AppState;
|
||||
use crate::download::storage;
|
||||
|
||||
pub async fn list_uploaded_files(
|
||||
Path(user_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let file_list = storage::scan_uploaded_files(&user_id);
|
||||
(StatusCode::OK, Json(file_list))
|
||||
}
|
||||
|
||||
pub async fn get_file_info(
|
||||
Path((user_id, filename)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let base_path = format!("/Users/accusys/Downloads/{}", user_id);
|
||||
let file_path = PathBuf::from(&base_path).join(&filename);
|
||||
|
||||
if !file_path.exists() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "File not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let metadata = std::fs::metadata(&file_path).unwrap();
|
||||
let file_size = metadata.len();
|
||||
let file_hash = if file_size > 0 {
|
||||
storage::compute_file_hash(&file_path).ok()
|
||||
} else {
|
||||
Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string())
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"filename": filename,
|
||||
"file_size": file_size,
|
||||
"file_hash": file_hash,
|
||||
"file_path": file_path.to_string_lossy(),
|
||||
"user_id": user_id
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
12
markbase-core/src/download/mod.rs
Normal file
12
markbase-core/src/download/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
pub mod models;
|
||||
pub mod db;
|
||||
pub mod handlers;
|
||||
pub mod storage;
|
||||
pub mod product_handlers;
|
||||
pub mod download_handler;
|
||||
|
||||
pub use models::*;
|
||||
pub use db::{DownloadDb, Product, ProductFile, SeriesStats};
|
||||
pub use handlers::*;
|
||||
pub use product_handlers::*;
|
||||
pub use download_handler::*;
|
||||
42
markbase-core/src/download/models.rs
Normal file
42
markbase-core/src/download/models.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Product {
|
||||
pub product_id: String,
|
||||
pub series: String,
|
||||
pub model: String,
|
||||
pub description: Option<String>,
|
||||
pub platform_support: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DownloadFile {
|
||||
pub file_id: String,
|
||||
pub product_id: String,
|
||||
pub download_type: String,
|
||||
pub platform: Option<String>,
|
||||
pub filename: String,
|
||||
pub file_size: i64,
|
||||
pub file_path: String,
|
||||
pub checksum: Option<String>,
|
||||
pub download_count: i64,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProductSeries {
|
||||
pub series: String,
|
||||
pub product_count: i64,
|
||||
pub file_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DownloadStats {
|
||||
pub total_products: i64,
|
||||
pub total_files: i64,
|
||||
pub total_downloads: i64,
|
||||
pub series_stats: Vec<ProductSeries>,
|
||||
}
|
||||
212
markbase-core/src/download/product_handlers.rs
Normal file
212
markbase-core/src/download/product_handlers.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{Json, IntoResponse},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::server::AppState;
|
||||
use crate::download::db::{DownloadDb, Product, ProductFile, SeriesStats};
|
||||
|
||||
pub async fn list_all_products(
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
|
||||
|
||||
match DownloadDb::new(&db_path) {
|
||||
Ok(db) => {
|
||||
match db.get_all_products() {
|
||||
Ok(products) => (StatusCode::OK, Json(json!({
|
||||
"products": products,
|
||||
"total": products.len()
|
||||
}))),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
|
||||
"error": e.to_string()
|
||||
}))),
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
|
||||
"error": e.to_string()
|
||||
}))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_products_by_series(
|
||||
Path(series): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
|
||||
|
||||
match DownloadDb::new(&db_path) {
|
||||
Ok(db) => {
|
||||
match db.get_products_by_series(&series) {
|
||||
Ok(products) => (StatusCode::OK, Json(json!({
|
||||
"series": series,
|
||||
"products": products,
|
||||
"total": products.len()
|
||||
}))),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
|
||||
"error": e.to_string()
|
||||
}))),
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
|
||||
"error": e.to_string()
|
||||
}))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_series_stats(
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
|
||||
|
||||
match DownloadDb::new(&db_path) {
|
||||
Ok(db) => {
|
||||
match db.get_series_stats() {
|
||||
Ok(stats) => (StatusCode::OK, Json(json!({
|
||||
"series_stats": stats,
|
||||
"total_series": stats.len()
|
||||
}))),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
|
||||
"error": e.to_string()
|
||||
}))),
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
|
||||
"error": e.to_string()
|
||||
}))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_product_files(
|
||||
Path(product_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
|
||||
|
||||
match DownloadDb::new(&db_path) {
|
||||
Ok(db) => {
|
||||
match db.get_files_by_product(product_id) {
|
||||
Ok(files) => (StatusCode::OK, Json(json!({
|
||||
"product_id": product_id,
|
||||
"files": files,
|
||||
"total_files": files.len(),
|
||||
"total_size": files.iter().map(|f| f.file_size).sum::<u64>()
|
||||
}))),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
|
||||
"error": e.to_string()
|
||||
}))),
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
|
||||
"error": e.to_string()
|
||||
}))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_product_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
|
||||
|
||||
let product_name = payload["product_name"].as_str().unwrap_or("");
|
||||
let series = payload["series"].as_str().unwrap_or("");
|
||||
let description = payload["description"].as_str();
|
||||
|
||||
match DownloadDb::new(&db_path) {
|
||||
Ok(mut db) => {
|
||||
match db.create_product(product_name, series, description) {
|
||||
Ok(product_id) => (StatusCode::OK, Json(json!({
|
||||
"ok": true,
|
||||
"product_id": product_id,
|
||||
"product_name": product_name,
|
||||
"series": series
|
||||
}))),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
|
||||
"error": e.to_string()
|
||||
}))),
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
|
||||
"error": e.to_string()
|
||||
}))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn assign_files_to_product(
|
||||
Path(product_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
|
||||
|
||||
let files_vec = payload["files"].as_array().cloned().unwrap_or_default();
|
||||
let files = files_vec.as_slice();
|
||||
|
||||
match DownloadDb::new(&db_path) {
|
||||
Ok(mut db) => {
|
||||
let mut assigned_count = 0;
|
||||
let mut errors = vec![];
|
||||
|
||||
for file in files {
|
||||
let file_path = file["file_path"].as_str().unwrap_or("");
|
||||
let file_name = file["file_name"].as_str().unwrap_or("");
|
||||
let file_size = file["file_size"].as_u64().unwrap_or(0);
|
||||
let file_hash = file["file_hash"].as_str();
|
||||
|
||||
match db.add_file_to_product(product_id, file_path, file_name, file_size, file_hash) {
|
||||
Ok(_) => assigned_count += 1,
|
||||
Err(e) => {
|
||||
errors.push(format!("Failed to assign {}: {}", file_path, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
(StatusCode::OK, Json(json!({
|
||||
"ok": true,
|
||||
"product_id": product_id,
|
||||
"assigned_count": assigned_count
|
||||
})))
|
||||
} else {
|
||||
(StatusCode::PARTIAL_CONTENT, Json(json!({
|
||||
"ok": true,
|
||||
"product_id": product_id,
|
||||
"assigned_count": assigned_count,
|
||||
"errors": errors
|
||||
})))
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
|
||||
"error": e.to_string()
|
||||
}))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_product(
|
||||
Path(product_id): Path<i64>,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
let db_path = format!("{}{}", state.db_dir.replace("users", "downloads"), "/products.sqlite");
|
||||
|
||||
match DownloadDb::new(&db_path) {
|
||||
Ok(mut db) => {
|
||||
match db.delete_product_with_files(product_id) {
|
||||
Ok((deleted_files, deleted_product)) => (StatusCode::OK, Json(json!({
|
||||
"ok": true,
|
||||
"product_id": product_id,
|
||||
"deleted_files": deleted_files,
|
||||
"deleted_product": deleted_product
|
||||
}))),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
|
||||
"error": e.to_string()
|
||||
}))),
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({
|
||||
"error": e.to_string()
|
||||
}))),
|
||||
}
|
||||
}
|
||||
119
markbase-core/src/download/storage.rs
Normal file
119
markbase-core/src/download/storage.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileInfo {
|
||||
pub filename: String,
|
||||
pub file_size: u64,
|
||||
pub file_hash: Option<String>,
|
||||
pub file_path: String,
|
||||
pub relative_path: String,
|
||||
pub upload_time: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileListResponse {
|
||||
pub user_id: String,
|
||||
pub base_path: String,
|
||||
pub files: Vec<FileInfo>,
|
||||
pub total_files: usize,
|
||||
pub total_size: u64,
|
||||
}
|
||||
|
||||
pub fn scan_uploaded_files(user_id: &str) -> FileListResponse {
|
||||
let base_path = format!("/Users/accusys/Downloads/{}", user_id);
|
||||
let path = Path::new(&base_path);
|
||||
|
||||
let mut files = Vec::new();
|
||||
let mut total_size = 0u64;
|
||||
|
||||
if path.exists() {
|
||||
scan_directory_recursive(path, path, &mut files, &mut total_size);
|
||||
}
|
||||
|
||||
FileListResponse {
|
||||
user_id: user_id.to_string(),
|
||||
base_path: base_path,
|
||||
total_files: files.len(),
|
||||
total_size,
|
||||
files,
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_directory_recursive(
|
||||
base: &Path,
|
||||
current: &Path,
|
||||
files: &mut Vec<FileInfo>,
|
||||
total_size: &mut u64,
|
||||
) {
|
||||
if let Ok(entries) = std::fs::read_dir(current) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_file() {
|
||||
let filename = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let file_size = entry.metadata()
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let relative_path = path.strip_prefix(base)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| filename.clone());
|
||||
|
||||
let upload_time = entry.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| {
|
||||
let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
|
||||
chrono::DateTime::from_timestamp(duration.as_secs() as i64, 0)
|
||||
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||
})
|
||||
.unwrap_or_else(|| chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string());
|
||||
|
||||
let file_hash = if file_size > 0 {
|
||||
compute_file_hash(&path).ok()
|
||||
} else {
|
||||
Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string())
|
||||
};
|
||||
|
||||
files.push(FileInfo {
|
||||
filename,
|
||||
file_size,
|
||||
file_hash,
|
||||
file_path: path.to_string_lossy().to_string(),
|
||||
relative_path,
|
||||
upload_time,
|
||||
});
|
||||
|
||||
*total_size += file_size;
|
||||
} else if path.is_dir() {
|
||||
scan_directory_recursive(base, &path, files, total_size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_file_hash(path: &Path) -> Result<String, std::io::Error> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 8192];
|
||||
|
||||
loop {
|
||||
let bytes_read = file.read(&mut buffer)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..bytes_read]);
|
||||
}
|
||||
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
Reference in New Issue
Block a user