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:
caoqianming 2026-05-19 08:57:40 +08:00
parent ed1067f6e5
commit a7f5c85032
10 changed files with 306 additions and 136 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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")]

View File

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

View File

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