refactor(core): move model and util modules into shared crate
This commit is contained in:
parent
cf26a1f319
commit
b34c898089
|
|
@ -1,2 +1,4 @@
|
|||
pub mod bootstrap;
|
||||
pub mod platform_context;
|
||||
pub mod model;
|
||||
pub mod platform_context;
|
||||
pub mod util;
|
||||
|
|
|
|||
|
|
@ -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<ScanMode> 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<Self, Self::Err> {
|
||||
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<String>,
|
||||
pub security_mode: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub namespace_index: Option<i32>,
|
||||
pub identifier_type: Option<String>, // i/s/g/b
|
||||
pub identifier: Option<String>,
|
||||
|
||||
pub browse_name: String,
|
||||
pub display_name: Option<String>,
|
||||
pub node_class: String, // Object/Variable/Method coil/input topic
|
||||
pub parent_id: Option<Uuid>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub unit: Option<String>,
|
||||
pub tag_id: Option<Uuid>,
|
||||
pub equipment_id: Option<Uuid>,
|
||||
pub signal_role: Option<String>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
|
||||
pub struct Equipment {
|
||||
pub id: Uuid,
|
||||
pub unit_id: Option<Uuid>,
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub kind: Option<String>,
|
||||
pub description: Option<String>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
|
||||
pub struct ControlUnit {
|
||||
pub id: Uuid,
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
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<Utc>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
|
||||
pub struct EventRecord {
|
||||
pub id: Uuid,
|
||||
pub event_type: String,
|
||||
pub level: String,
|
||||
pub unit_id: Option<Uuid>,
|
||||
pub equipment_id: Option<Uuid>,
|
||||
pub source_id: Option<Uuid>,
|
||||
pub message: String,
|
||||
pub payload: Option<Json<serde_json::Value>>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow, Clone)]
|
||||
pub struct Page {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub data: Json<HashMap<String, Uuid>>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(serialize_with = "utc_to_local_str")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
pub mod datetime;
|
||||
pub mod log;
|
||||
pub mod pagination;
|
||||
pub mod response;
|
||||
pub mod single_instance;
|
||||
pub mod validator;
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
use chrono::{DateTime, Local, Utc};
|
||||
use serde::Serializer;
|
||||
|
||||
pub fn utc_to_local_string(date: &DateTime<Utc>) -> String {
|
||||
date.with_timezone(&Local).format("%Y-%m-%d %H:%M:%S%.3f").to_string()
|
||||
}
|
||||
|
||||
pub fn utc_to_local_str<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let formatted = utc_to_local_string(date);
|
||||
serializer.serialize_str(&formatted)
|
||||
}
|
||||
|
||||
pub fn option_utc_to_local_str<S>(date: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match date {
|
||||
Some(d) => utc_to_local_str(d, serializer),
|
||||
None => serializer.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
|
@ -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<non_blocking::WorkerGuard> = 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();
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::serde_as;
|
||||
use validator::Validate;
|
||||
|
||||
/// 鍒嗛〉鍝嶅簲缁撴瀯
|
||||
#[derive(Serialize)]
|
||||
pub struct PaginatedResponse<T> {
|
||||
pub data: Vec<T>,
|
||||
pub total: i64,
|
||||
pub page: u32,
|
||||
pub page_size: i32,
|
||||
pub total_pages: u32,
|
||||
}
|
||||
|
||||
impl<T> PaginatedResponse<T> {
|
||||
/// 鍒涘缓鍒嗛〉鍝嶅簲
|
||||
pub fn new(data: Vec<T>, 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Value>,
|
||||
}
|
||||
|
||||
impl ErrResp {
|
||||
pub fn new(err_code: i32, err_msg: impl Into<String>, detail: Option<Value>) -> Self {
|
||||
Self {
|
||||
err_code,
|
||||
err_msg: err_msg.into(),
|
||||
err_detail: detail,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum ApiErr {
|
||||
Unauthorized(String, Option<Value>),
|
||||
Forbidden(String, Option<Value>),
|
||||
BadRequest(String, Option<Value>),
|
||||
NotFound(String, Option<Value>),
|
||||
Internal(String, Option<Value>),
|
||||
}
|
||||
|
||||
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<Error> 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<SqlxError> 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<QueryRejection> 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<PathRejection> 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<JsonRejection> 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<FormRejection> 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()
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
use fs2::FileExt;
|
||||
use std::{
|
||||
fs::{File, OpenOptions},
|
||||
io,
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
pub fn try_acquire(name: &str) -> io::Result<SingleInstanceGuard> {
|
||||
SingleInstanceGuard::acquire(name)
|
||||
}
|
||||
|
||||
pub struct SingleInstanceGuard {
|
||||
_file: File,
|
||||
}
|
||||
|
||||
impl SingleInstanceGuard {
|
||||
fn acquire(name: &str) -> io::Result<Self> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
use crate::util::response::ApiErr;
|
||||
use serde_json::{json, Value};
|
||||
use validator::ValidationErrors;
|
||||
|
||||
impl From<ValidationErrors> 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<String> = 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)))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
use plc_platform_core::model::Equipment;
|
||||
|
||||
#[test]
|
||||
fn equipment_type_is_public() {
|
||||
let _equipment: Option<Equipment> = None;
|
||||
}
|
||||
Loading…
Reference in New Issue