refactor(core): move model and util modules into shared crate

This commit is contained in:
caoqianming 2026-04-15 13:14:24 +08:00
parent cf26a1f319
commit b34c898089
10 changed files with 557 additions and 1 deletions

View File

@ -1,2 +1,4 @@
pub mod bootstrap;
pub mod platform_context;
pub mod model;
pub mod platform_context;
pub mod util;

View File

@ -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>,
}

View File

@ -0,0 +1,6 @@
pub mod datetime;
pub mod log;
pub mod pagination;
pub mod response;
pub mod single_instance;
pub mod validator;

View File

@ -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(),
}
}

View File

@ -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();
}

View File

@ -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
}
}

View File

@ -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()
}))
)
}
}

View File

@ -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"));
}
}

View File

@ -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)))
}
}

View File

@ -0,0 +1,6 @@
use plc_platform_core::model::Equipment;
#[test]
fn equipment_type_is_public() {
let _equipment: Option<Equipment> = None;
}