diff --git a/crates/plc_platform_core/src/lib.rs b/crates/plc_platform_core/src/lib.rs index b862446..f3a6d1e 100644 --- a/crates/plc_platform_core/src/lib.rs +++ b/crates/plc_platform_core/src/lib.rs @@ -1,2 +1,4 @@ pub mod bootstrap; -pub mod platform_context; \ No newline at end of file +pub mod model; +pub mod platform_context; +pub mod util; diff --git a/crates/plc_platform_core/src/model.rs b/crates/plc_platform_core/src/model.rs new file mode 100644 index 0000000..cc7c97f --- /dev/null +++ b/crates/plc_platform_core/src/model.rs @@ -0,0 +1,178 @@ +use crate::util::datetime::utc_to_local_str; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::types::Json; +use sqlx::FromRow; +use std::collections::HashMap; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ScanMode { + Poll, + Subscribe, +} + +impl ScanMode { + pub fn as_str(&self) -> &'static str { + match self { + ScanMode::Poll => "poll", + ScanMode::Subscribe => "subscribe", + } + } +} + +impl From for String { + fn from(mode: ScanMode) -> Self { + mode.as_str().to_string() + } +} + +impl std::fmt::Display for ScanMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl std::str::FromStr for ScanMode { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "poll" => Ok(ScanMode::Poll), + "subscribe" => Ok(ScanMode::Subscribe), + _ => Err(format!("Invalid scan mode: {}", s)), + } + } +} + +#[derive(Debug, Serialize, Deserialize, FromRow, Clone)] +pub struct Source { + pub id: Uuid, + pub name: String, + pub protocol: String, // opcua, modbus + pub endpoint: String, + pub security_policy: Option, + pub security_mode: Option, + pub username: Option, + pub password: Option, + pub enabled: bool, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +#[allow(dead_code)] +pub struct Node { + pub id: Uuid, + pub source_id: Uuid, + pub external_id: String, // ns=2;s=Temperature + + // comment fixed + pub namespace_uri: Option, + pub namespace_index: Option, + pub identifier_type: Option, // i/s/g/b + pub identifier: Option, + + pub browse_name: String, + pub display_name: Option, + pub node_class: String, // Object/Variable/Method coil/input topic + pub parent_id: Option, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +#[allow(dead_code)] +pub struct Point { + pub id: Uuid, + pub node_id: Uuid, + pub name: String, + pub description: Option, + pub unit: Option, + pub tag_id: Option, + pub equipment_id: Option, + pub signal_role: Option, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct PointSubscriptionInfo { + pub point_id: Uuid, + pub external_id: String, +} + +#[derive(Debug, Serialize, Deserialize, FromRow, Clone)] +pub struct Tag { + pub id: Uuid, + pub name: String, + pub description: Option, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow, Clone)] +pub struct Equipment { + pub id: Uuid, + pub unit_id: Option, + pub code: String, + pub name: String, + pub kind: Option, + pub description: Option, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow, Clone)] +pub struct ControlUnit { + pub id: Uuid, + pub code: String, + pub name: String, + pub description: Option, + pub enabled: bool, + pub run_time_sec: i32, + pub stop_time_sec: i32, + pub acc_time_sec: i32, + pub bl_time_sec: i32, + pub require_manual_ack_after_fault: bool, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow, Clone)] +pub struct EventRecord { + pub id: Uuid, + pub event_type: String, + pub level: String, + pub unit_id: Option, + pub equipment_id: Option, + pub source_id: Option, + pub message: String, + pub payload: Option>, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow, Clone)] +pub struct Page { + pub id: Uuid, + pub name: String, + pub data: Json>, + #[serde(serialize_with = "utc_to_local_str")] + pub created_at: DateTime, + #[serde(serialize_with = "utc_to_local_str")] + pub updated_at: DateTime, +} diff --git a/crates/plc_platform_core/src/util.rs b/crates/plc_platform_core/src/util.rs new file mode 100644 index 0000000..edb3ea5 --- /dev/null +++ b/crates/plc_platform_core/src/util.rs @@ -0,0 +1,6 @@ +pub mod datetime; +pub mod log; +pub mod pagination; +pub mod response; +pub mod single_instance; +pub mod validator; diff --git a/crates/plc_platform_core/src/util/datetime.rs b/crates/plc_platform_core/src/util/datetime.rs new file mode 100644 index 0000000..20ec5ce --- /dev/null +++ b/crates/plc_platform_core/src/util/datetime.rs @@ -0,0 +1,24 @@ +use chrono::{DateTime, Local, Utc}; +use serde::Serializer; + +pub fn utc_to_local_string(date: &DateTime) -> String { + date.with_timezone(&Local).format("%Y-%m-%d %H:%M:%S%.3f").to_string() +} + +pub fn utc_to_local_str(date: &DateTime, serializer: S) -> Result +where + S: Serializer, +{ + let formatted = utc_to_local_string(date); + serializer.serialize_str(&formatted) +} + +pub fn option_utc_to_local_str(date: &Option>, serializer: S) -> Result +where + S: Serializer, +{ + match date { + Some(d) => utc_to_local_str(d, serializer), + None => serializer.serialize_none(), + } +} diff --git a/crates/plc_platform_core/src/util/log.rs b/crates/plc_platform_core/src/util/log.rs new file mode 100644 index 0000000..d7bd548 --- /dev/null +++ b/crates/plc_platform_core/src/util/log.rs @@ -0,0 +1,37 @@ +use std::sync::OnceLock; +use time::UtcOffset; +use tracing_subscriber::{fmt, prelude::*, EnvFilter}; +use tracing_appender::{rolling, non_blocking}; + +static LOG_GUARD: OnceLock = OnceLock::new(); + +pub fn init_logger() { + std::fs::create_dir_all("./logs").ok(); + + let file_appender = rolling::daily("./logs", "app.log"); + let (file_writer, guard) = non_blocking(file_appender); + LOG_GUARD.set(guard).ok(); + let timer = fmt::time::OffsetTime::new( + UtcOffset::from_hms(8, 0, 0).unwrap(), + time::format_description::well_known::Rfc3339, + ); + + tracing_subscriber::registry() + .with(EnvFilter::from_default_env()) + .with( + fmt::layer() + .compact() + .with_timer(timer.clone()) + .with_writer(std::io::stdout), + ) + .with( + fmt::layer() + .json() + .with_timer(timer) + .with_writer(file_writer) + .with_ansi(false) + .with_current_span(false) + .with_span_list(false), + ) + .init(); +} diff --git a/crates/plc_platform_core/src/util/pagination.rs b/crates/plc_platform_core/src/util/pagination.rs new file mode 100644 index 0000000..158c7c0 --- /dev/null +++ b/crates/plc_platform_core/src/util/pagination.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; +use validator::Validate; + +/// 鍒嗛〉鍝嶅簲缁撴瀯 +#[derive(Serialize)] +pub struct PaginatedResponse { + pub data: Vec, + pub total: i64, + pub page: u32, + pub page_size: i32, + pub total_pages: u32, +} + +impl PaginatedResponse { + /// 鍒涘缓鍒嗛〉鍝嶅簲 + pub fn new(data: Vec, total: i64, page: u32, page_size: i32) -> Self { + let total_pages = if page_size > 0 { + ((total as f64) / (page_size as f64)).ceil() as u32 + } else { + 0 + }; + + Self { + data, + total, + page, + page_size, + total_pages, + } + } +} + +/// 鍒嗛〉鏌ヨ鍙傛暟 +#[serde_as] +#[derive(Debug, Deserialize, Validate)] +pub struct PaginationParams { + #[validate(range(min = 1))] + #[serde_as(as = "serde_with::DisplayFromStr")] + #[serde(default = "default_page")] + pub page: u32, + #[validate(range(min = -1, max = 100))] + #[serde_as(as = "serde_with::DisplayFromStr")] + #[serde(default = "default_page_size")] + pub page_size: i32, +} + +fn default_page() -> u32 { + 1 +} + +fn default_page_size() -> i32 { + 20 +} + +impl PaginationParams { + /// 璁$畻鍋忕Щ閲? + pub fn offset(&self) -> u32 { + (self.page - 1) * self.page_size.max(0) as u32 + } +} diff --git a/crates/plc_platform_core/src/util/response.rs b/crates/plc_platform_core/src/util/response.rs new file mode 100644 index 0000000..df45e62 --- /dev/null +++ b/crates/plc_platform_core/src/util/response.rs @@ -0,0 +1,143 @@ +use anyhow::Error; +use axum::{ + Json, + http::StatusCode, + response::IntoResponse, + extract::rejection::{ + QueryRejection, + PathRejection, + JsonRejection, + FormRejection, + }, +}; +use serde::Serialize; +use serde_json::Value; +use sqlx::Error as SqlxError; + +#[derive(Debug, Serialize)] +pub struct ErrResp { + pub err_code: i32, + pub err_msg: String, + pub err_detail: Option, +} + +impl ErrResp { + pub fn new(err_code: i32, err_msg: impl Into, detail: Option) -> Self { + Self { + err_code, + err_msg: err_msg.into(), + err_detail: detail, + } + } +} + +#[derive(Debug)] +#[allow(dead_code)] +pub enum ApiErr { + Unauthorized(String, Option), + Forbidden(String, Option), + BadRequest(String, Option), + NotFound(String, Option), + Internal(String, Option), +} + +impl IntoResponse for ApiErr { + fn into_response(self) -> axum::response::Response { + match self { + ApiErr::Unauthorized(msg, detail) => { + (StatusCode::UNAUTHORIZED, Json(ErrResp::new(401, msg, detail))).into_response() + } + ApiErr::Forbidden(msg, detail) => { + (StatusCode::FORBIDDEN, Json(ErrResp::new(403, msg, detail))).into_response() + } + ApiErr::BadRequest(msg, detail) => { + (StatusCode::BAD_REQUEST, Json(ErrResp::new(400, msg, detail))).into_response() + } + ApiErr::NotFound(msg, detail) => { + (StatusCode::NOT_FOUND, Json(ErrResp::new(404, msg, detail))).into_response() + } + ApiErr::Internal(msg, detail) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrResp::new(500, msg, detail)), + ) + .into_response(), + } + } +} + +impl From for ApiErr { + fn from(err: Error) -> Self { + tracing::error!("Error: {:?}; root_cause: {}", err, err.root_cause()); + ApiErr::Internal("internal server error".to_string(), None) + } +} + +impl From for ApiErr { + fn from(err: SqlxError) -> Self { + match err { + SqlxError::RowNotFound => { + ApiErr::NotFound("Resource not found".into(), None) + } + SqlxError::Database(db_err) => { + if db_err.code().as_deref() == Some("23505") { + ApiErr::BadRequest("data already exists".into(), None) + } else { + tracing::error!("Database error: {}", db_err); + ApiErr::Internal("Database error".into(), None) + } + } + _ => { + tracing::error!("Database error: {}", err); + ApiErr::Internal("Database error".into(), None) + } + } + } +} + +impl From for ApiErr { + fn from(rejection: QueryRejection) -> Self { + tracing::warn!("Query parameter error: {}", rejection); + ApiErr::BadRequest( + "Invalid query parameters".to_string(), + Some(serde_json::json!({ + "detail": rejection.to_string() + })) + ) + } +} + +impl From for ApiErr { + fn from(rejection: PathRejection) -> Self { + tracing::warn!("Path parameter error: {}", rejection); + ApiErr::BadRequest( + "Invalid path parameter".to_string(), + Some(serde_json::json!({ + "detail": rejection.to_string() + })) + ) + } +} + +impl From for ApiErr { + fn from(rejection: JsonRejection) -> Self { + tracing::warn!("JSON parsing error: {}", rejection); + ApiErr::BadRequest( + "Invalid JSON format".to_string(), + Some(serde_json::json!({ + "detail": rejection.to_string() + })) + ) + } +} + +impl From for ApiErr { + fn from(rejection: FormRejection) -> Self { + tracing::warn!("Form data error: {}", rejection); + ApiErr::BadRequest( + "Invalid form data".to_string(), + Some(serde_json::json!({ + "detail": rejection.to_string() + })) + ) + } +} diff --git a/crates/plc_platform_core/src/util/single_instance.rs b/crates/plc_platform_core/src/util/single_instance.rs new file mode 100644 index 0000000..efc1d35 --- /dev/null +++ b/crates/plc_platform_core/src/util/single_instance.rs @@ -0,0 +1,65 @@ +use fs2::FileExt; +use std::{ + fs::{File, OpenOptions}, + io, + path::PathBuf, +}; + +pub fn try_acquire(name: &str) -> io::Result { + SingleInstanceGuard::acquire(name) +} + +pub struct SingleInstanceGuard { + _file: File, +} + +impl SingleInstanceGuard { + fn acquire(name: &str) -> io::Result { + let lock_path = lock_file_path(name); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(lock_path)?; + + if let Err(err) = file.try_lock_exclusive() { + if err.kind() == io::ErrorKind::WouldBlock { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "another PLC Control instance is already running", + )); + } + return Err(io::Error::new( + err.kind(), + format!("failed to lock single-instance file: {}", err), + )); + } + + Ok(Self { _file: file }) + } +} + +fn lock_file_path(name: &str) -> PathBuf { + let sanitized: String = name + .chars() + .map(|ch| match ch { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => ch, + _ => '_', + }) + .collect(); + + std::env::temp_dir().join(format!("{}.lock", sanitized)) +} + +#[cfg(test)] +mod tests { + use super::lock_file_path; + + #[test] + fn lock_path_is_sanitized() { + let path = lock_file_path("PLCControl/Gateway:test"); + let path_str = path.to_string_lossy(); + assert!(path_str.ends_with("PLCControl_Gateway_test.lock")); + } +} diff --git a/crates/plc_platform_core/src/util/validator.rs b/crates/plc_platform_core/src/util/validator.rs new file mode 100644 index 0000000..0e210b3 --- /dev/null +++ b/crates/plc_platform_core/src/util/validator.rs @@ -0,0 +1,34 @@ +use crate::util::response::ApiErr; +use serde_json::{json, Value}; +use validator::ValidationErrors; + +impl From for ApiErr { + fn from(errors: ValidationErrors) -> Self { + // 鏋勫缓璇︾粏鐨勯敊璇俊鎭? + let mut error_details = serde_json::Map::new(); + let mut first_error_msg = String::from("璇锋眰鍙傛暟楠岃瘉澶辫触"); + + for (field, field_errors) in errors.field_errors() { + let error_list: Vec = field_errors + .iter() + .map(|e| { + e.message.as_ref() + .map(|m| m.to_string()) + .unwrap_or_else(|| e.code.to_string()) + }) + .collect(); + error_details.insert(field.to_string(), json!(error_list)); + + // 鑾峰彇绗竴涓瓧娈电殑绗竴涓敊璇俊鎭? + if first_error_msg == "璇锋眰鍙傛暟楠岃瘉澶辫触" && !error_list.is_empty() { + if let Some(msg) = field_errors[0].message.as_ref() { + first_error_msg = format!("{}: {}", field, msg); + } else { + first_error_msg = format!("{}: {}", field, field_errors[0].code); + } + } + } + + ApiErr::BadRequest(first_error_msg, Some(Value::Object(error_details))) + } +} diff --git a/crates/plc_platform_core/tests/model_smoke.rs b/crates/plc_platform_core/tests/model_smoke.rs new file mode 100644 index 0000000..4dd3a87 --- /dev/null +++ b/crates/plc_platform_core/tests/model_smoke.rs @@ -0,0 +1,6 @@ +use plc_platform_core::model::Equipment; + +#[test] +fn equipment_type_is_public() { + let _equipment: Option = None; +}