use anyhow::{anyhow, Result}; use aws_config::BehaviorVersion; use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::Client; use chrono::TimeZone; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use tokio::fs; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AttendanceDriveConfig { pub bucket_name: String, pub prefix: String, pub sync_enabled: bool, pub region: Option, } impl Default for AttendanceDriveConfig { fn default() -> Self { Self { bucket_name: "attendance".to_string(), prefix: "records/".to_string(), sync_enabled: true, region: None, } } } #[derive(Debug, Clone)] pub struct AttendanceDriveService { config: AttendanceDriveConfig, client: Client, } impl AttendanceDriveService { pub async fn new(config: AttendanceDriveConfig) -> Result { let sdk_config = if let Some(region) = &config.region { aws_config::defaults(BehaviorVersion::latest()) .region(aws_config::Region::new(region.clone())) .load() .await } else { aws_config::defaults(BehaviorVersion::latest()).load().await }; let client = Client::new(&sdk_config); Ok(Self { config, client }) } pub fn with_client(config: AttendanceDriveConfig, client: Client) -> Self { Self { config, client } } fn get_record_key(&self, record_id: &str) -> String { format!("{}{}", self.config.prefix, record_id) } pub async fn upload_record(&self, record_id: &str, data: Vec) -> Result<()> { let key = self.get_record_key(record_id); log::info!( "Uploading attendance record {} to s3://{}/{}", record_id, self.config.bucket_name, key ); let body = ByteStream::from(data); self.client .put_object() .bucket(&self.config.bucket_name) .key(&key) .body(body) .content_type("application/octet-stream") .send() .await .map_err(|e| anyhow!("Failed to upload attendance record: {}", e))?; log::debug!("Successfully uploaded attendance record {}", record_id); Ok(()) } pub async fn download_record(&self, record_id: &str) -> Result> { let key = self.get_record_key(record_id); log::info!( "Downloading attendance record {} from s3://{}/{}", record_id, self.config.bucket_name, key ); let result = self .client .get_object() .bucket(&self.config.bucket_name) .key(&key) .send() .await .map_err(|e| anyhow!("Failed to download attendance record: {}", e))?; let data = result .body .collect() .await .map_err(|e| anyhow!("Failed to read attendance record body: {}", e))?; log::debug!("Successfully downloaded attendance record {}", record_id); Ok(data.into_bytes().to_vec()) } pub async fn list_records(&self, prefix: Option<&str>) -> Result> { let list_prefix = if let Some(p) = prefix { format!("{}{}", self.config.prefix, p) } else { self.config.prefix.clone() }; log::info!( "Listing attendance records in s3://{}/{}", self.config.bucket_name, list_prefix ); let mut records = Vec::new(); let mut continuation_token = None; loop { let mut request = self .client .list_objects_v2() .bucket(&self.config.bucket_name) .prefix(&list_prefix) .max_keys(1000); if let Some(token) = continuation_token { request = request.continuation_token(token); } let result = request .send() .await .map_err(|e| anyhow!("Failed to list attendance records: {}", e))?; if let Some(contents) = result.contents { for obj in contents { if let Some(key) = obj.key { if let Some(record_id) = key.strip_prefix(&self.config.prefix) { records.push(record_id.to_string()); } } } } if result.is_truncated.unwrap_or(false) { continuation_token = result.next_continuation_token; } else { break; } } log::debug!("Found {} attendance records", records.len()); Ok(records) } pub async fn delete_record(&self, record_id: &str) -> Result<()> { let key = self.get_record_key(record_id); log::info!( "Deleting attendance record {} from s3://{}/{}", record_id, self.config.bucket_name, key ); self.client .delete_object() .bucket(&self.config.bucket_name) .key(&key) .send() .await .map_err(|e| anyhow!("Failed to delete attendance record: {}", e))?; log::debug!("Successfully deleted attendance record {}", record_id); Ok(()) } pub async fn delete_records(&self, record_ids: &[String]) -> Result<()> { if record_ids.is_empty() { return Ok(()); } log::info!( "Batch deleting {} attendance records from bucket {}", record_ids.len(), self.config.bucket_name ); for chunk in record_ids.chunks(1000) { let objects: Vec<_> = chunk .iter() .map(|id| { aws_sdk_s3::types::ObjectIdentifier::builder() .key(self.get_record_key(id)) .build() .unwrap() }) .collect(); let delete = aws_sdk_s3::types::Delete::builder() .set_objects(Some(objects)) .build() .map_err(|e| anyhow!("Failed to build delete request: {}", e))?; self.client .delete_objects() .bucket(&self.config.bucket_name) .delete(delete) .send() .await .map_err(|e| anyhow!("Failed to batch delete attendance records: {}", e))?; } log::debug!( "Successfully batch deleted {} attendance records", record_ids.len() ); Ok(()) } pub async fn record_exists(&self, record_id: &str) -> Result { let key = self.get_record_key(record_id); match self .client .head_object() .bucket(&self.config.bucket_name) .key(&key) .send() .await { Ok(_) => Ok(true), Err(sdk_err) => { if sdk_err.to_string().contains("404") || sdk_err.to_string().contains("NotFound") { Ok(false) } else { Err(anyhow!( "Failed to check attendance record existence: {}", sdk_err )) } } } } pub async fn sync_records(&self, local_path: PathBuf) -> Result { if !self.config.sync_enabled { log::debug!("Attendance drive sync is disabled"); return Ok(SyncResult::default()); } log::info!( "Syncing attendance records from {} to s3://{}/{}", local_path.display(), self.config.bucket_name, self.config.prefix ); if !local_path.exists() { return Err(anyhow!( "Local path does not exist: {}", local_path.display() )); } let mut uploaded = 0; let mut failed = 0; let mut skipped = 0; let mut entries = fs::read_dir(&local_path) .await .map_err(|e| anyhow!("Failed to read local directory: {}", e))?; while let Some(entry) = entries .next_entry() .await .map_err(|e| anyhow!("Failed to read directory entry: {}", e))? { let path = entry.path(); if !path.is_file() { continue; } let file_name = match path.file_name().and_then(|n| n.to_str()) { Some(name) => name.to_string(), None => { log::warn!("Skipping file with invalid name: {}", path.display()); skipped += 1; continue; } }; if self.record_exists(&file_name).await? { log::debug!("Record {} already exists in drive, skipping", file_name); skipped += 1; continue; } match fs::read(&path).await { Ok(data) => match self.upload_record(&file_name, data).await { Ok(_) => { log::debug!("Uploaded attendance record: {}", file_name); uploaded += 1; } Err(e) => { log::error!("Failed to upload {}: {}", file_name, e); failed += 1; } }, Err(e) => { log::error!("Failed to read file {}: {}", path.display(), e); failed += 1; } } } let result = SyncResult { uploaded, failed, skipped, }; log::info!( "Sync completed: {} uploaded, {} failed, {} skipped", result.uploaded, result.failed, result.skipped ); Ok(result) } pub async fn get_record_metadata(&self, record_id: &str) -> Result { let key = self.get_record_key(record_id); let result = self .client .head_object() .bucket(&self.config.bucket_name) .key(&key) .send() .await .map_err(|e| anyhow!("Failed to get attendance record metadata: {}", e))?; Ok(RecordMetadata { size: result.content_length.unwrap_or(0) as usize, last_modified: result .last_modified .and_then(|t| t.to_millis().ok()) .map(|ms| chrono::Utc.timestamp_millis_opt(ms).unwrap()), content_type: result.content_type, etag: result.e_tag, }) } } #[derive(Debug, Default, Clone, Serialize, Deserialize)] pub struct SyncResult { pub uploaded: usize, pub failed: usize, pub skipped: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecordMetadata { pub size: usize, pub last_modified: Option>, pub content_type: Option, pub etag: Option, }