Persist event subject_type/subject_id and add ops event timeline API
EventInsert + EventRecord + record_event now carry the subject_type / subject_id columns added by the P1 migration. Ops events populate "segment" / "station" subjects so the timeline can be filtered without parsing event_type strings. Platform SourceCreated / Updated / Deleted attribute themselves to subject_type="source". Adds get_events_*_filtered in core and exposes GET /api/event on ops with event_type / event_type_prefix / subject_type / subject_id query params, closing design doc §14 "event 表能按 ops.* 和 subject_type/subject_id 查到全链路事件". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ed1067f6e5
commit
a7f5c85032
|
|
@ -118,6 +118,8 @@ async fn handle_control_event(
|
||||||
unit_id: *unit_id,
|
unit_id: *unit_id,
|
||||||
equipment_id: Some(*equipment_id),
|
equipment_id: Some(*equipment_id),
|
||||||
source_id: None,
|
source_id: None,
|
||||||
|
subject_type: None,
|
||||||
|
subject_id: None,
|
||||||
message: format!("Start command sent to equipment {}", code),
|
message: format!("Start command sent to equipment {}", code),
|
||||||
payload: serde_json::json!({
|
payload: serde_json::json!({
|
||||||
"equipment_id": equipment_id,
|
"equipment_id": equipment_id,
|
||||||
|
|
@ -138,6 +140,8 @@ async fn handle_control_event(
|
||||||
unit_id: *unit_id,
|
unit_id: *unit_id,
|
||||||
equipment_id: Some(*equipment_id),
|
equipment_id: Some(*equipment_id),
|
||||||
source_id: None,
|
source_id: None,
|
||||||
|
subject_type: None,
|
||||||
|
subject_id: None,
|
||||||
message: format!("Stop command sent to equipment {}", code),
|
message: format!("Stop command sent to equipment {}", code),
|
||||||
payload: serde_json::json!({
|
payload: serde_json::json!({
|
||||||
"equipment_id": equipment_id,
|
"equipment_id": equipment_id,
|
||||||
|
|
@ -154,6 +158,8 @@ async fn handle_control_event(
|
||||||
unit_id: Some(*unit_id),
|
unit_id: Some(*unit_id),
|
||||||
equipment_id: None,
|
equipment_id: None,
|
||||||
source_id: None,
|
source_id: None,
|
||||||
|
subject_type: None,
|
||||||
|
subject_id: None,
|
||||||
message: format!("Auto control started for unit {}", code),
|
message: format!("Auto control started for unit {}", code),
|
||||||
payload: serde_json::json!({ "unit_id": unit_id }),
|
payload: serde_json::json!({ "unit_id": unit_id }),
|
||||||
})
|
})
|
||||||
|
|
@ -166,6 +172,8 @@ async fn handle_control_event(
|
||||||
unit_id: Some(*unit_id),
|
unit_id: Some(*unit_id),
|
||||||
equipment_id: None,
|
equipment_id: None,
|
||||||
source_id: None,
|
source_id: None,
|
||||||
|
subject_type: None,
|
||||||
|
subject_id: None,
|
||||||
message: format!("Auto control stopped for unit {}", code),
|
message: format!("Auto control stopped for unit {}", code),
|
||||||
payload: serde_json::json!({ "unit_id": unit_id }),
|
payload: serde_json::json!({ "unit_id": unit_id }),
|
||||||
})
|
})
|
||||||
|
|
@ -182,6 +190,8 @@ async fn handle_control_event(
|
||||||
unit_id: Some(*unit_id),
|
unit_id: Some(*unit_id),
|
||||||
equipment_id: Some(*equipment_id),
|
equipment_id: Some(*equipment_id),
|
||||||
source_id: None,
|
source_id: None,
|
||||||
|
subject_type: None,
|
||||||
|
subject_id: None,
|
||||||
message: format!(
|
message: format!(
|
||||||
"Fault locked for unit {} by equipment {}",
|
"Fault locked for unit {} by equipment {}",
|
||||||
unit_code, eq_code
|
unit_code, eq_code
|
||||||
|
|
@ -197,6 +207,8 @@ async fn handle_control_event(
|
||||||
unit_id: Some(*unit_id),
|
unit_id: Some(*unit_id),
|
||||||
equipment_id: None,
|
equipment_id: None,
|
||||||
source_id: None,
|
source_id: None,
|
||||||
|
subject_type: None,
|
||||||
|
subject_id: None,
|
||||||
message: format!("Fault acknowledged for unit {}", code),
|
message: format!("Fault acknowledged for unit {}", code),
|
||||||
payload: serde_json::json!({ "unit_id": unit_id }),
|
payload: serde_json::json!({ "unit_id": unit_id }),
|
||||||
})
|
})
|
||||||
|
|
@ -209,6 +221,8 @@ async fn handle_control_event(
|
||||||
unit_id: Some(*unit_id),
|
unit_id: Some(*unit_id),
|
||||||
equipment_id: None,
|
equipment_id: None,
|
||||||
source_id: None,
|
source_id: None,
|
||||||
|
subject_type: None,
|
||||||
|
subject_id: None,
|
||||||
message: format!("Communication locked for unit {}", code),
|
message: format!("Communication locked for unit {}", code),
|
||||||
payload: serde_json::json!({ "unit_id": unit_id }),
|
payload: serde_json::json!({ "unit_id": unit_id }),
|
||||||
})
|
})
|
||||||
|
|
@ -221,6 +235,8 @@ async fn handle_control_event(
|
||||||
unit_id: Some(*unit_id),
|
unit_id: Some(*unit_id),
|
||||||
equipment_id: None,
|
equipment_id: None,
|
||||||
source_id: None,
|
source_id: None,
|
||||||
|
subject_type: None,
|
||||||
|
subject_id: None,
|
||||||
message: format!("Communication recovered for unit {}", code),
|
message: format!("Communication recovered for unit {}", code),
|
||||||
payload: serde_json::json!({ "unit_id": unit_id }),
|
payload: serde_json::json!({ "unit_id": unit_id }),
|
||||||
})
|
})
|
||||||
|
|
@ -237,6 +253,8 @@ async fn handle_control_event(
|
||||||
unit_id: Some(*unit_id),
|
unit_id: Some(*unit_id),
|
||||||
equipment_id: Some(*equipment_id),
|
equipment_id: Some(*equipment_id),
|
||||||
source_id: None,
|
source_id: None,
|
||||||
|
subject_type: None,
|
||||||
|
subject_id: None,
|
||||||
message: format!(
|
message: format!(
|
||||||
"Unit {} switched to local control via equipment {}",
|
"Unit {} switched to local control via equipment {}",
|
||||||
unit_code, eq_code
|
unit_code, eq_code
|
||||||
|
|
@ -252,6 +270,8 @@ async fn handle_control_event(
|
||||||
unit_id: Some(*unit_id),
|
unit_id: Some(*unit_id),
|
||||||
equipment_id: None,
|
equipment_id: None,
|
||||||
source_id: None,
|
source_id: None,
|
||||||
|
subject_type: None,
|
||||||
|
subject_id: None,
|
||||||
message: format!(
|
message: format!(
|
||||||
"Unit {} returned to remote control; auto control requires manual restart",
|
"Unit {} returned to remote control; auto control requires manual restart",
|
||||||
code
|
code
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ const CONTROL_EVENT_CHANNEL_CAPACITY: usize = 1024;
|
||||||
|
|
||||||
/// Operation-system business events.
|
/// Operation-system business events.
|
||||||
///
|
///
|
||||||
/// Variants here will grow as engine phases land. Each variant maps to a
|
/// Each variant maps to a row in the `event` table (via `record_event`) and
|
||||||
/// row in the `event` table (via `record_event`) and follows the `ops.*`
|
/// follows the `ops.*` namespace from design doc §8.1. Every record carries
|
||||||
/// namespace agreed in the design doc §8.1.
|
/// `subject_type` + `subject_id` so the front-end can filter the timeline
|
||||||
|
/// for one segment / station without joining on event_type strings.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AppEvent {
|
pub enum AppEvent {
|
||||||
SegmentAutoStarted {
|
SegmentAutoStarted {
|
||||||
|
|
@ -99,6 +100,26 @@ impl EventManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn segment_event(
|
||||||
|
event_type: &'static str,
|
||||||
|
level: &'static str,
|
||||||
|
segment_id: Uuid,
|
||||||
|
message: String,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
) -> EventInsert {
|
||||||
|
EventInsert {
|
||||||
|
event_type,
|
||||||
|
level,
|
||||||
|
unit_id: None,
|
||||||
|
equipment_id: None,
|
||||||
|
source_id: None,
|
||||||
|
subject_type: Some("segment"),
|
||||||
|
subject_id: Some(segment_id),
|
||||||
|
message,
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_event(
|
async fn handle_event(
|
||||||
event: AppEvent,
|
event: AppEvent,
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
|
|
@ -106,93 +127,75 @@ async fn handle_event(
|
||||||
_metadata: &MetadataCache,
|
_metadata: &MetadataCache,
|
||||||
) {
|
) {
|
||||||
let record: Option<EventInsert> = match &event {
|
let record: Option<EventInsert> = match &event {
|
||||||
AppEvent::SegmentAutoStarted { segment_id } => Some(EventInsert {
|
AppEvent::SegmentAutoStarted { segment_id } => Some(segment_event(
|
||||||
event_type: "ops.segment.auto_started",
|
"ops.segment.auto_started",
|
||||||
level: "info",
|
"info",
|
||||||
unit_id: None,
|
*segment_id,
|
||||||
equipment_id: None,
|
format!("Segment {} auto control started", segment_id),
|
||||||
source_id: None,
|
serde_json::json!({ "segment_id": segment_id }),
|
||||||
message: format!("Segment {} auto control started", segment_id),
|
)),
|
||||||
payload: serde_json::json!({ "segment_id": segment_id }),
|
AppEvent::SegmentAutoStopped { segment_id } => Some(segment_event(
|
||||||
}),
|
"ops.segment.auto_stopped",
|
||||||
AppEvent::SegmentAutoStopped { segment_id } => Some(EventInsert {
|
"info",
|
||||||
event_type: "ops.segment.auto_stopped",
|
*segment_id,
|
||||||
level: "info",
|
format!("Segment {} auto control stopped", segment_id),
|
||||||
unit_id: None,
|
serde_json::json!({ "segment_id": segment_id }),
|
||||||
equipment_id: None,
|
)),
|
||||||
source_id: None,
|
|
||||||
message: format!("Segment {} auto control stopped", segment_id),
|
|
||||||
payload: serde_json::json!({ "segment_id": segment_id }),
|
|
||||||
}),
|
|
||||||
AppEvent::SegmentStepAdvanced {
|
AppEvent::SegmentStepAdvanced {
|
||||||
segment_id,
|
segment_id,
|
||||||
step_no,
|
step_no,
|
||||||
} => Some(EventInsert {
|
} => Some(segment_event(
|
||||||
event_type: "ops.segment.step_advanced",
|
"ops.segment.step_advanced",
|
||||||
level: "info",
|
"info",
|
||||||
unit_id: None,
|
*segment_id,
|
||||||
equipment_id: None,
|
format!("Segment {} advanced to step {}", segment_id, step_no),
|
||||||
source_id: None,
|
serde_json::json!({ "segment_id": segment_id, "step_no": step_no }),
|
||||||
message: format!("Segment {} advanced to step {}", segment_id, step_no),
|
)),
|
||||||
payload: serde_json::json!({ "segment_id": segment_id, "step_no": step_no }),
|
AppEvent::SegmentCompleted { segment_id } => Some(segment_event(
|
||||||
}),
|
"ops.segment.completed",
|
||||||
AppEvent::SegmentCompleted { segment_id } => Some(EventInsert {
|
"info",
|
||||||
event_type: "ops.segment.completed",
|
*segment_id,
|
||||||
level: "info",
|
format!("Segment {} completed", segment_id),
|
||||||
unit_id: None,
|
serde_json::json!({ "segment_id": segment_id }),
|
||||||
equipment_id: None,
|
)),
|
||||||
source_id: None,
|
AppEvent::SegmentBlocked { segment_id, reason } => Some(segment_event(
|
||||||
message: format!("Segment {} completed", segment_id),
|
"ops.segment.blocked",
|
||||||
payload: serde_json::json!({ "segment_id": segment_id }),
|
"warn",
|
||||||
}),
|
*segment_id,
|
||||||
AppEvent::SegmentBlocked { segment_id, reason } => Some(EventInsert {
|
format!("Segment {} blocked: {}", segment_id, reason),
|
||||||
event_type: "ops.segment.blocked",
|
serde_json::json!({ "segment_id": segment_id, "reason": reason }),
|
||||||
level: "warn",
|
)),
|
||||||
unit_id: None,
|
|
||||||
equipment_id: None,
|
|
||||||
source_id: None,
|
|
||||||
message: format!("Segment {} blocked: {}", segment_id, reason),
|
|
||||||
payload: serde_json::json!({ "segment_id": segment_id, "reason": reason }),
|
|
||||||
}),
|
|
||||||
AppEvent::SegmentFaultLocked {
|
AppEvent::SegmentFaultLocked {
|
||||||
segment_id,
|
segment_id,
|
||||||
message,
|
message,
|
||||||
} => Some(EventInsert {
|
} => Some(segment_event(
|
||||||
event_type: "ops.segment.fault_locked",
|
"ops.segment.fault_locked",
|
||||||
level: "error",
|
"error",
|
||||||
unit_id: None,
|
*segment_id,
|
||||||
equipment_id: None,
|
format!("Segment {} fault locked: {}", segment_id, message),
|
||||||
source_id: None,
|
serde_json::json!({ "segment_id": segment_id, "message": message }),
|
||||||
message: format!("Segment {} fault locked: {}", segment_id, message),
|
)),
|
||||||
payload: serde_json::json!({ "segment_id": segment_id, "message": message }),
|
AppEvent::SegmentFaultAcked { segment_id } => Some(segment_event(
|
||||||
}),
|
"ops.segment.fault_acked",
|
||||||
AppEvent::SegmentFaultAcked { segment_id } => Some(EventInsert {
|
"info",
|
||||||
event_type: "ops.segment.fault_acked",
|
*segment_id,
|
||||||
level: "info",
|
format!("Segment {} fault acknowledged", segment_id),
|
||||||
unit_id: None,
|
serde_json::json!({ "segment_id": segment_id }),
|
||||||
equipment_id: None,
|
)),
|
||||||
source_id: None,
|
AppEvent::SegmentCommLocked { segment_id } => Some(segment_event(
|
||||||
message: format!("Segment {} fault acknowledged", segment_id),
|
"ops.segment.comm_locked",
|
||||||
payload: serde_json::json!({ "segment_id": segment_id }),
|
"warn",
|
||||||
}),
|
*segment_id,
|
||||||
AppEvent::SegmentCommLocked { segment_id } => Some(EventInsert {
|
format!("Segment {} communication locked", segment_id),
|
||||||
event_type: "ops.segment.comm_locked",
|
serde_json::json!({ "segment_id": segment_id }),
|
||||||
level: "warn",
|
)),
|
||||||
unit_id: None,
|
AppEvent::SegmentCommRecovered { segment_id } => Some(segment_event(
|
||||||
equipment_id: None,
|
"ops.segment.comm_recovered",
|
||||||
source_id: None,
|
"info",
|
||||||
message: format!("Segment {} communication locked", segment_id),
|
*segment_id,
|
||||||
payload: serde_json::json!({ "segment_id": segment_id }),
|
format!("Segment {} communication recovered", segment_id),
|
||||||
}),
|
serde_json::json!({ "segment_id": segment_id }),
|
||||||
AppEvent::SegmentCommRecovered { segment_id } => Some(EventInsert {
|
)),
|
||||||
event_type: "ops.segment.comm_recovered",
|
|
||||||
level: "info",
|
|
||||||
unit_id: None,
|
|
||||||
equipment_id: None,
|
|
||||||
source_id: None,
|
|
||||||
message: format!("Segment {} communication recovered", segment_id),
|
|
||||||
payload: serde_json::json!({ "segment_id": segment_id }),
|
|
||||||
}),
|
|
||||||
AppEvent::StationStateChanged {
|
AppEvent::StationStateChanged {
|
||||||
station_id,
|
station_id,
|
||||||
presence,
|
presence,
|
||||||
|
|
@ -203,6 +206,8 @@ async fn handle_event(
|
||||||
unit_id: None,
|
unit_id: None,
|
||||||
equipment_id: None,
|
equipment_id: None,
|
||||||
source_id: None,
|
source_id: None,
|
||||||
|
subject_type: Some("station"),
|
||||||
|
subject_id: Some(*station_id),
|
||||||
message: format!(
|
message: format!(
|
||||||
"Station {} state changed (presence={}, vacancy={})",
|
"Station {} state changed (presence={}, vacancy={})",
|
||||||
station_id, presence, vacancy
|
station_id, presence, vacancy
|
||||||
|
|
@ -216,48 +221,36 @@ async fn handle_event(
|
||||||
AppEvent::AlarmActionTimeout {
|
AppEvent::AlarmActionTimeout {
|
||||||
segment_id,
|
segment_id,
|
||||||
step_no,
|
step_no,
|
||||||
} => Some(EventInsert {
|
} => Some(segment_event(
|
||||||
event_type: "ops.alarm.action_timeout",
|
"ops.alarm.action_timeout",
|
||||||
level: "error",
|
"error",
|
||||||
unit_id: None,
|
*segment_id,
|
||||||
equipment_id: None,
|
format!("Action timeout on segment {} step {}", segment_id, step_no),
|
||||||
source_id: None,
|
serde_json::json!({ "segment_id": segment_id, "step_no": step_no }),
|
||||||
message: format!(
|
)),
|
||||||
"Action timeout on segment {} step {}",
|
|
||||||
segment_id, step_no
|
|
||||||
),
|
|
||||||
payload: serde_json::json!({ "segment_id": segment_id, "step_no": step_no }),
|
|
||||||
}),
|
|
||||||
AppEvent::AlarmSignalConflict {
|
AppEvent::AlarmSignalConflict {
|
||||||
segment_id,
|
segment_id,
|
||||||
message,
|
message,
|
||||||
} => Some(EventInsert {
|
} => Some(segment_event(
|
||||||
event_type: "ops.alarm.signal_conflict",
|
"ops.alarm.signal_conflict",
|
||||||
level: "error",
|
"error",
|
||||||
unit_id: None,
|
*segment_id,
|
||||||
equipment_id: None,
|
format!("Signal conflict on segment {}: {}", segment_id, message),
|
||||||
source_id: None,
|
serde_json::json!({ "segment_id": segment_id, "message": message }),
|
||||||
message: format!("Signal conflict on segment {}: {}", segment_id, message),
|
)),
|
||||||
payload: serde_json::json!({ "segment_id": segment_id, "message": message }),
|
|
||||||
}),
|
|
||||||
AppEvent::AlarmResourceBusy {
|
AppEvent::AlarmResourceBusy {
|
||||||
segment_id,
|
segment_id,
|
||||||
resource_key,
|
resource_key,
|
||||||
} => Some(EventInsert {
|
} => Some(segment_event(
|
||||||
event_type: "ops.alarm.resource_busy",
|
"ops.alarm.resource_busy",
|
||||||
level: "warn",
|
"warn",
|
||||||
unit_id: None,
|
*segment_id,
|
||||||
equipment_id: None,
|
format!("Resource {} busy for segment {}", resource_key, segment_id),
|
||||||
source_id: None,
|
serde_json::json!({
|
||||||
message: format!(
|
|
||||||
"Resource {} busy for segment {}",
|
|
||||||
resource_key, segment_id
|
|
||||||
),
|
|
||||||
payload: serde_json::json!({
|
|
||||||
"segment_id": segment_id,
|
"segment_id": segment_id,
|
||||||
"resource_key": resource_key
|
"resource_key": resource_key
|
||||||
}),
|
}),
|
||||||
}),
|
)),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(record) = record {
|
if let Some(record) = record {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
pub mod control;
|
pub mod control;
|
||||||
pub mod doc;
|
pub mod doc;
|
||||||
|
pub mod event;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod segment;
|
pub mod segment;
|
||||||
pub mod station;
|
pub mod station;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
//! Event timeline endpoint with subject filtering (design doc §9.3).
|
||||||
|
//!
|
||||||
|
//! `event_type` matches exact value or prefix when `event_type=ops.` style is
|
||||||
|
//! requested. `subject_type` / `subject_id` use the columns added by the P1
|
||||||
|
//! migration so the front-end can show a per-segment / per-station timeline
|
||||||
|
//! without parsing event_type strings.
|
||||||
|
|
||||||
|
use axum::{extract::{Query, State}, response::IntoResponse, Json};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use plc_platform_core::{
|
||||||
|
service::EventFilter,
|
||||||
|
util::{
|
||||||
|
pagination::{PaginatedResponse, PaginationParams},
|
||||||
|
response::ApiErr,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
|
pub struct GetEventListQuery {
|
||||||
|
#[validate(length(min = 1, max = 100))]
|
||||||
|
pub event_type: Option<String>,
|
||||||
|
#[validate(length(min = 1, max = 100))]
|
||||||
|
pub event_type_prefix: Option<String>,
|
||||||
|
#[validate(length(min = 1, max = 32))]
|
||||||
|
pub subject_type: Option<String>,
|
||||||
|
pub subject_id: Option<Uuid>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub pagination: PaginationParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_event_list(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<GetEventListQuery>,
|
||||||
|
) -> Result<impl IntoResponse, ApiErr> {
|
||||||
|
query.validate()?;
|
||||||
|
|
||||||
|
let filter = EventFilter {
|
||||||
|
unit_id: None,
|
||||||
|
event_type: query.event_type.as_deref(),
|
||||||
|
event_type_prefix: query.event_type_prefix.as_deref(),
|
||||||
|
subject_type: query.subject_type.as_deref(),
|
||||||
|
subject_id: query.subject_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let total =
|
||||||
|
plc_platform_core::service::get_events_count_filtered(&state.platform.pool, &filter)
|
||||||
|
.await?;
|
||||||
|
let data = plc_platform_core::service::get_events_paginated_filtered(
|
||||||
|
&state.platform.pool,
|
||||||
|
&filter,
|
||||||
|
query.pagination.page_size,
|
||||||
|
query.pagination.offset(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(PaginatedResponse::new(
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
query.pagination.page,
|
||||||
|
query.pagination.page_size,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
@ -109,6 +109,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||||
.route(
|
.route(
|
||||||
"/api/runtime/station/{station_id}",
|
"/api/runtime/station/{station_id}",
|
||||||
get(crate::handler::runtime::get_station_runtime),
|
get(crate::handler::runtime::get_station_runtime),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/event",
|
||||||
|
get(crate::handler::event::get_event_list),
|
||||||
);
|
);
|
||||||
|
|
||||||
let ops_routes = Router::new()
|
let ops_routes = Router::new()
|
||||||
|
|
|
||||||
|
|
@ -92,3 +92,20 @@ async fn operation_system_router_exposes_control_batch_routes() {
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
|
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Event timeline endpoint is GET-only — POST should be METHOD_NOT_ALLOWED.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn operation_system_router_exposes_event_timeline() {
|
||||||
|
let response = build_app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri("/api/event")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("router should answer request");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,10 @@ pub struct EventInsert {
|
||||||
pub unit_id: Option<Uuid>,
|
pub unit_id: Option<Uuid>,
|
||||||
pub equipment_id: Option<Uuid>,
|
pub equipment_id: Option<Uuid>,
|
||||||
pub source_id: Option<Uuid>,
|
pub source_id: Option<Uuid>,
|
||||||
|
/// Generic owner-type tag (e.g. "segment" / "station") used by ops business
|
||||||
|
/// events. Design doc §4.2.8 attribution columns.
|
||||||
|
pub subject_type: Option<&'static str>,
|
||||||
|
pub subject_id: Option<Uuid>,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
pub payload: Value,
|
pub payload: Value,
|
||||||
}
|
}
|
||||||
|
|
@ -131,8 +135,11 @@ pub async fn record_event(
|
||||||
|
|
||||||
let inserted = sqlx::query_as::<_, EventRecord>(
|
let inserted = sqlx::query_as::<_, EventRecord>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO event (event_type, level, unit_id, equipment_id, source_id, message, payload)
|
INSERT INTO event (
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
event_type, level, unit_id, equipment_id, source_id,
|
||||||
|
subject_type, subject_id, message, payload
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -141,6 +148,8 @@ pub async fn record_event(
|
||||||
.bind(event.unit_id)
|
.bind(event.unit_id)
|
||||||
.bind(event.equipment_id)
|
.bind(event.equipment_id)
|
||||||
.bind(event.source_id)
|
.bind(event.source_id)
|
||||||
|
.bind(event.subject_type)
|
||||||
|
.bind(event.subject_id)
|
||||||
.bind(event.message)
|
.bind(event.message)
|
||||||
.bind(sqlx::types::Json(envelope.payload))
|
.bind(sqlx::types::Json(envelope.payload))
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
|
|
@ -176,6 +185,8 @@ pub async fn record_platform_event(
|
||||||
unit_id: None,
|
unit_id: None,
|
||||||
equipment_id: None,
|
equipment_id: None,
|
||||||
source_id: Some(*source_id),
|
source_id: Some(*source_id),
|
||||||
|
subject_type: Some("source"),
|
||||||
|
subject_id: Some(*source_id),
|
||||||
message: format!("Source {} created", name),
|
message: format!("Source {} created", name),
|
||||||
payload: serde_json::json!({ "source_id": source_id }),
|
payload: serde_json::json!({ "source_id": source_id }),
|
||||||
})
|
})
|
||||||
|
|
@ -188,6 +199,8 @@ pub async fn record_platform_event(
|
||||||
unit_id: None,
|
unit_id: None,
|
||||||
equipment_id: None,
|
equipment_id: None,
|
||||||
source_id: Some(*source_id),
|
source_id: Some(*source_id),
|
||||||
|
subject_type: Some("source"),
|
||||||
|
subject_id: Some(*source_id),
|
||||||
message: format!("Source {} updated", name),
|
message: format!("Source {} updated", name),
|
||||||
payload: serde_json::json!({ "source_id": source_id }),
|
payload: serde_json::json!({ "source_id": source_id }),
|
||||||
})
|
})
|
||||||
|
|
@ -201,6 +214,8 @@ pub async fn record_platform_event(
|
||||||
unit_id: None,
|
unit_id: None,
|
||||||
equipment_id: None,
|
equipment_id: None,
|
||||||
source_id: None,
|
source_id: None,
|
||||||
|
subject_type: Some("source"),
|
||||||
|
subject_id: Some(*source_id),
|
||||||
message: format!("Source {} deleted", source_name),
|
message: format!("Source {} deleted", source_name),
|
||||||
payload: serde_json::json!({ "source_id": source_id }),
|
payload: serde_json::json!({ "source_id": source_id }),
|
||||||
}),
|
}),
|
||||||
|
|
@ -215,6 +230,8 @@ pub async fn record_platform_event(
|
||||||
unit_id: None,
|
unit_id: None,
|
||||||
equipment_id: None,
|
equipment_id: None,
|
||||||
source_id: Some(*source_id),
|
source_id: Some(*source_id),
|
||||||
|
subject_type: Some("source"),
|
||||||
|
subject_id: Some(*source_id),
|
||||||
message: format!("Created {} points for source {}", point_ids.len(), name),
|
message: format!("Created {} points for source {}", point_ids.len(), name),
|
||||||
payload: serde_json::json!({ "source_id": source_id, "point_ids": point_ids }),
|
payload: serde_json::json!({ "source_id": source_id, "point_ids": point_ids }),
|
||||||
})
|
})
|
||||||
|
|
@ -230,6 +247,8 @@ pub async fn record_platform_event(
|
||||||
unit_id: None,
|
unit_id: None,
|
||||||
equipment_id: None,
|
equipment_id: None,
|
||||||
source_id: Some(*source_id),
|
source_id: Some(*source_id),
|
||||||
|
subject_type: Some("source"),
|
||||||
|
subject_id: Some(*source_id),
|
||||||
message: format!("Deleted {} points for source {}", point_ids.len(), name),
|
message: format!("Deleted {} points for source {}", point_ids.len(), name),
|
||||||
payload: serde_json::json!({ "source_id": source_id, "point_ids": point_ids }),
|
payload: serde_json::json!({ "source_id": source_id, "point_ids": point_ids }),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,8 @@ pub struct EventRecord {
|
||||||
pub unit_id: Option<Uuid>,
|
pub unit_id: Option<Uuid>,
|
||||||
pub equipment_id: Option<Uuid>,
|
pub equipment_id: Option<Uuid>,
|
||||||
pub source_id: Option<Uuid>,
|
pub source_id: Option<Uuid>,
|
||||||
|
pub subject_type: Option<String>,
|
||||||
|
pub subject_id: Option<Uuid>,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
pub payload: Option<Json<serde_json::Value>>,
|
pub payload: Option<Json<serde_json::Value>>,
|
||||||
#[serde(serialize_with = "utc_to_local_str")]
|
#[serde(serialize_with = "utc_to_local_str")]
|
||||||
|
|
|
||||||
|
|
@ -12,21 +12,46 @@ pub struct EquipmentRolePoint {
|
||||||
pub signal_role: String,
|
pub signal_role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct EventFilter<'a> {
|
||||||
|
pub unit_id: Option<Uuid>,
|
||||||
|
pub event_type: Option<&'a str>,
|
||||||
|
/// `event_type` LIKE prefix, e.g. `ops.` matches all ops events.
|
||||||
|
pub event_type_prefix: Option<&'a str>,
|
||||||
|
pub subject_type: Option<&'a str>,
|
||||||
|
pub subject_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_event_filters<'a>(qb: &mut QueryBuilder<'a, sqlx::Postgres>, filter: &EventFilter<'a>) {
|
||||||
|
if let Some(unit_id) = filter.unit_id {
|
||||||
|
qb.push(" AND unit_id = ").push_bind(unit_id);
|
||||||
|
}
|
||||||
|
if let Some(event_type) = filter.event_type {
|
||||||
|
qb.push(" AND event_type = ").push_bind(event_type);
|
||||||
|
}
|
||||||
|
if let Some(prefix) = filter.event_type_prefix {
|
||||||
|
let pattern = format!("{}%", prefix);
|
||||||
|
qb.push(" AND event_type LIKE ").push_bind(pattern);
|
||||||
|
}
|
||||||
|
if let Some(subject_type) = filter.subject_type {
|
||||||
|
qb.push(" AND subject_type = ").push_bind(subject_type);
|
||||||
|
}
|
||||||
|
if let Some(subject_id) = filter.subject_id {
|
||||||
|
qb.push(" AND subject_id = ").push_bind(subject_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_events_count(
|
pub async fn get_events_count(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
unit_id: Option<Uuid>,
|
unit_id: Option<Uuid>,
|
||||||
event_type: Option<&str>,
|
event_type: Option<&str>,
|
||||||
) -> Result<i64, sqlx::Error> {
|
) -> Result<i64, sqlx::Error> {
|
||||||
let mut qb = QueryBuilder::new("SELECT COUNT(*)::BIGINT FROM event WHERE 1 = 1");
|
let filter = EventFilter {
|
||||||
|
unit_id,
|
||||||
if let Some(unit_id) = unit_id {
|
event_type,
|
||||||
qb.push(" AND unit_id = ").push_bind(unit_id);
|
..EventFilter::default()
|
||||||
}
|
};
|
||||||
if let Some(event_type) = event_type {
|
get_events_count_filtered(pool, &filter).await
|
||||||
qb.push(" AND event_type = ").push_bind(event_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
qb.build_query_scalar().fetch_one(pool).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_events_paginated(
|
pub async fn get_events_paginated(
|
||||||
|
|
@ -36,15 +61,32 @@ pub async fn get_events_paginated(
|
||||||
page_size: i32,
|
page_size: i32,
|
||||||
offset: u32,
|
offset: u32,
|
||||||
) -> Result<Vec<EventRecord>, sqlx::Error> {
|
) -> Result<Vec<EventRecord>, sqlx::Error> {
|
||||||
let mut qb = QueryBuilder::new("SELECT * FROM event WHERE 1 = 1");
|
let filter = EventFilter {
|
||||||
|
unit_id,
|
||||||
|
event_type,
|
||||||
|
..EventFilter::default()
|
||||||
|
};
|
||||||
|
get_events_paginated_filtered(pool, &filter, page_size, offset).await
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(unit_id) = unit_id {
|
pub async fn get_events_count_filtered(
|
||||||
qb.push(" AND unit_id = ").push_bind(unit_id);
|
pool: &PgPool,
|
||||||
}
|
filter: &EventFilter<'_>,
|
||||||
if let Some(event_type) = event_type {
|
) -> Result<i64, sqlx::Error> {
|
||||||
qb.push(" AND event_type = ").push_bind(event_type);
|
let mut qb = QueryBuilder::new("SELECT COUNT(*)::BIGINT FROM event WHERE 1 = 1");
|
||||||
|
apply_event_filters(&mut qb, filter);
|
||||||
|
qb.build_query_scalar().fetch_one(pool).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_events_paginated_filtered(
|
||||||
|
pool: &PgPool,
|
||||||
|
filter: &EventFilter<'_>,
|
||||||
|
page_size: i32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<EventRecord>, sqlx::Error> {
|
||||||
|
let mut qb = QueryBuilder::new("SELECT * FROM event WHERE 1 = 1");
|
||||||
|
apply_event_filters(&mut qb, filter);
|
||||||
|
|
||||||
qb.push(" ORDER BY created_at DESC");
|
qb.push(" ORDER BY created_at DESC");
|
||||||
|
|
||||||
if page_size != -1 {
|
if page_size != -1 {
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,11 @@
|
||||||
- `GET /api/runtime/overview` — 所有段 + 资源占用快照
|
- `GET /api/runtime/overview` — 所有段 + 资源占用快照
|
||||||
- `GET /api/runtime/segment/{id}` — 单段配置 + runtime
|
- `GET /api/runtime/segment/{id}` — 单段配置 + runtime
|
||||||
- `GET /api/runtime/station/{id}` — 工位信号 + 最新点位监控值
|
- `GET /api/runtime/station/{id}` — 工位信号 + 最新点位监控值
|
||||||
|
- `GET /api/event` — 事件时间线,参数:
|
||||||
|
- `event_type` — 精确匹配,例如 `ops.segment.fault_locked`
|
||||||
|
- `event_type_prefix` — 前缀匹配,例如 `ops.` 拉取全部 ops 事件
|
||||||
|
- `subject_type` / `subject_id` — 设计文档 §4.2.8 归因字段,可按段 / 工位 / 设备过滤
|
||||||
|
- 分页参数 `page` / `page_size`
|
||||||
|
|
||||||
## WebSocket(§8.2)
|
## WebSocket(§8.2)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue