1e186369c9
Project CI / AI game creator shell Rust crates (push) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/446 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
429 lines
16 KiB
Rust
429 lines
16 KiB
Rust
//! 客户端观测事件校验,不推导项目所有权或奖励资格。
|
|
use serde_json::Value;
|
|
use shared_contracts::agc_analytics::*;
|
|
use uuid::Uuid;
|
|
const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
|
|
trait EventDataRules {
|
|
fn status(&self) -> Option<Status>;
|
|
fn has_goal(&self) -> bool;
|
|
}
|
|
pub trait EventRules {
|
|
fn data(&self) -> Result<EventData, &'static str>;
|
|
fn validate(&self) -> Result<(), &'static str>;
|
|
}
|
|
impl EventDataRules for EventData {
|
|
fn status(&self) -> Option<Status> {
|
|
match self {
|
|
Self::EditorFocusStart(_) | Self::EditorFocusEnd(_) => None,
|
|
Self::AgentRunFailed(_) => Some(Status::Failed),
|
|
_ => Some(Status::Success),
|
|
}
|
|
}
|
|
|
|
fn has_goal(&self) -> bool {
|
|
matches!(
|
|
self,
|
|
Self::CreativeTaskSubmit(_)
|
|
| Self::AgentRunCompleted(_)
|
|
| Self::AgentRunFailed(_)
|
|
| Self::ProjectRevisionCreated(_)
|
|
| Self::PreviewReady(_)
|
|
| Self::ProjectSave(_)
|
|
)
|
|
}
|
|
}
|
|
|
|
impl EventRules for Event {
|
|
fn data(&self) -> Result<EventData, &'static str> {
|
|
if !self.properties.is_object() {
|
|
return Err("invalid_properties");
|
|
}
|
|
// 可选字段不可得时省略;显式 null 仅用于合同指定的 nullable 字段。
|
|
let optional = match self.event_name.as_str() {
|
|
"project_create_success" => &["project_template_id"][..],
|
|
"agent_run_completed" | "agent_run_failed" | "project_save" => &["revision_id"][..],
|
|
"project_revision_created" => &["files_changed_count"][..],
|
|
"preview_ready" => &["ready_duration_ms"][..],
|
|
_ => &[][..],
|
|
};
|
|
if optional
|
|
.iter()
|
|
.any(|key| self.properties.get(key).is_some_and(Value::is_null))
|
|
{
|
|
return Err("invalid_optional_property");
|
|
}
|
|
serde_json::from_value(serde_json::json!({
|
|
"event_name": self.event_name,
|
|
"properties": self.properties,
|
|
}))
|
|
.map_err(|_| "invalid_properties")
|
|
}
|
|
|
|
fn validate(&self) -> Result<(), &'static str> {
|
|
if self.schema_version != 1
|
|
|| !uuid(&self.event_id)
|
|
|| !uuid(&self.editor_session_id)
|
|
|| !id(&self.client_version)
|
|
|| !optional_id(&self.user_id)
|
|
|| !optional_id(&self.project_id)
|
|
|| !optional_id(&self.creative_task_id)
|
|
|| !optional_id(&self.agent_turn_id)
|
|
|| !valid_time(&self.event_time)
|
|
{
|
|
return Err("invalid_envelope");
|
|
}
|
|
let data = self.data()?;
|
|
if self.status != data.status()
|
|
|| self.error_code.is_some() != matches!(data, EventData::AgentRunFailed(_))
|
|
{
|
|
return Err("invalid_status");
|
|
}
|
|
if data.has_goal() {
|
|
if self.project_id.is_none() || self.creative_task_id != self.project_id {
|
|
return Err("invalid_goal");
|
|
}
|
|
} else if self.creative_task_id.is_some() {
|
|
return Err("unexpected_goal");
|
|
}
|
|
let is_run = matches!(
|
|
data,
|
|
EventData::AgentRunCompleted(_) | EventData::AgentRunFailed(_)
|
|
);
|
|
if is_run {
|
|
if !self.agent_run_id.as_deref().is_some_and(uuid) {
|
|
return Err("invalid_run");
|
|
}
|
|
} else if self.agent_run_id.is_some() || self.agent_turn_id.is_some() {
|
|
return Err("unexpected_run");
|
|
}
|
|
let editor = self.source == Source::Editor;
|
|
let agent = matches!(self.source, Source::Direct | Source::DesignAgent);
|
|
let valid = match data {
|
|
EventData::EditorSessionStart(p) => editor && p.first_project_id == self.project_id,
|
|
EventData::EditorSessionEnd(p) => {
|
|
editor && p.last_project_id == self.project_id && safe(p.session_duration_ms)
|
|
}
|
|
EventData::EditorFocusStart(p) => {
|
|
editor && uuid(&p.focus_interval_id) && p.active_project_id == self.project_id
|
|
}
|
|
EventData::EditorFocusEnd(p) => {
|
|
editor
|
|
&& uuid(&p.focus_interval_id)
|
|
&& p.active_project_id == self.project_id
|
|
&& safe(p.focus_duration_ms)
|
|
}
|
|
EventData::ProjectCreateSuccess(p) => {
|
|
editor && self.project_id.is_some() && optional_id(&p.project_template_id)
|
|
}
|
|
EventData::ProjectOpen(_) => editor && self.project_id.is_some(),
|
|
EventData::CreativeTaskSubmit(_) => agent,
|
|
EventData::AgentRunCompleted(p) | EventData::AgentRunFailed(p) => {
|
|
agent
|
|
&& ((self.source == Source::Direct) == (p.agent_type == AgentType::GameAgent))
|
|
&& ((self.status == Some(Status::Failed))
|
|
== (p.end_reason == RunEndReason::Failed))
|
|
&& safe(p.duration_ms)
|
|
&& safe(Some(p.retry_index))
|
|
&& optional_id(&p.revision_id)
|
|
}
|
|
EventData::ProjectRevisionCreated(p) => {
|
|
id(&p.revision_id)
|
|
&& safe(p.files_changed_count)
|
|
&& match p.revision_source {
|
|
RevisionSource::Agent => agent,
|
|
RevisionSource::AssetCanvas => self.source == Source::AssetCanvas,
|
|
RevisionSource::ResourceEditor => self.source == Source::ResourceEditor,
|
|
RevisionSource::UiEditor => self.source == Source::UiEditor,
|
|
RevisionSource::ManualEdit => self.source == Source::Manual,
|
|
RevisionSource::SystemProjection => self.source == Source::System,
|
|
}
|
|
}
|
|
EventData::PreviewReady(p) => id(&p.preview_version) && safe(p.ready_duration_ms),
|
|
EventData::ProjectSave(p) => optional_id(&p.revision_id),
|
|
};
|
|
if valid {
|
|
Ok(())
|
|
} else {
|
|
Err("invalid_event_fields")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn id(value: &str) -> bool {
|
|
!value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control)
|
|
}
|
|
fn optional_id(value: &Option<String>) -> bool {
|
|
value.as_deref().is_none_or(id)
|
|
}
|
|
fn uuid(value: &str) -> bool {
|
|
Uuid::parse_str(value).is_ok_and(|v| v.get_version_num() == 4 && v.to_string() == value)
|
|
}
|
|
fn safe(value: Option<u64>) -> bool {
|
|
value.is_none_or(|v| v <= MAX_SAFE_INTEGER)
|
|
}
|
|
fn valid_time(value: &str) -> bool {
|
|
value.len() == 24
|
|
&& value.ends_with("Z")
|
|
&& value.as_bytes()[10] == b'T'
|
|
&& value.as_bytes()[19] == b'.'
|
|
&& time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
|
|
.is_ok()
|
|
}
|
|
|
|
pub fn validate_agc_analytics_batch(batch: &AgcAnalyticsBatch) -> Result<(), &'static str> {
|
|
if batch.schema_version != 1
|
|
|| batch.destination_origin.len() > 2048
|
|
|| !uuid(&batch.batch_id)
|
|
|| !id(&batch.user_id)
|
|
|| batch.events.is_empty()
|
|
|| batch.events.len() > 500
|
|
{
|
|
return Err("invalid_batch");
|
|
}
|
|
let origin = url::Url::parse(&batch.destination_origin).map_err(|_| "invalid_origin")?;
|
|
if !matches!(origin.scheme(), "https" | "http")
|
|
|| origin.host_str().is_none()
|
|
|| !origin.username().is_empty()
|
|
|| origin.password().is_some()
|
|
|| origin.origin().ascii_serialization() != batch.destination_origin
|
|
{
|
|
return Err("invalid_origin");
|
|
}
|
|
let mut ids = std::collections::HashSet::new();
|
|
let mut bytes = 0usize;
|
|
for event in &batch.events {
|
|
event.validate()?;
|
|
if event.user_id.as_deref() != Some(batch.user_id.as_str()) {
|
|
return Err("identity_mismatch");
|
|
}
|
|
if !ids.insert(&event.event_id) {
|
|
return Err("duplicate_event_id");
|
|
}
|
|
bytes += serde_json::to_vec(event)
|
|
.map_err(|_| "invalid_event")?
|
|
.len()
|
|
+ 1;
|
|
}
|
|
if bytes > 1024 * 1024 {
|
|
return Err("events_too_large");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct AgcTrackingCursor {
|
|
pub snapshot: i64,
|
|
pub event_time: i64,
|
|
pub event_id: String,
|
|
pub filter_key: String,
|
|
}
|
|
/// 游标绑定查询条件;分页大小不参与条件,刷新时不带游标。
|
|
pub fn agc_tracking_filter_key(
|
|
query: &shared_contracts::admin::AdminAgcTrackingEventListQuery,
|
|
) -> String {
|
|
let mut filters = query.clone();
|
|
filters.cursor = None;
|
|
filters.limit = None;
|
|
serde_json::to_string(&filters).expect("query serializes")
|
|
}
|
|
pub fn agc_time_micros(value: &str) -> Result<i64, &'static str> {
|
|
let time = time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
|
|
.map_err(|_| "invalid_agc_query")?;
|
|
i64::try_from(time.unix_timestamp_nanos() / 1000).map_err(|_| "invalid_agc_query")
|
|
}
|
|
pub fn validate_agc_tracking_query(
|
|
query: &shared_contracts::admin::AdminAgcTrackingEventListQuery,
|
|
) -> Result<Option<AgcTrackingCursor>, &'static str> {
|
|
for value in [
|
|
&query.user_id,
|
|
&query.project_id,
|
|
&query.creative_task_id,
|
|
&query.agent_run_id,
|
|
&query.event_name,
|
|
&query.client_version,
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
if !id(value) {
|
|
return Err("invalid_agc_query");
|
|
}
|
|
}
|
|
let start = query
|
|
.start_time
|
|
.as_deref()
|
|
.map(agc_time_micros)
|
|
.transpose()?;
|
|
let end = query.end_time.as_deref().map(agc_time_micros).transpose()?;
|
|
if start.zip(end).is_some_and(|(a, b)| a >= b) {
|
|
return Err("invalid_agc_query");
|
|
}
|
|
if let Some(raw) = &query.cursor {
|
|
if raw.len() > 8192 {
|
|
return Err("invalid_agc_cursor");
|
|
}
|
|
let cursor: AgcTrackingCursor =
|
|
serde_json::from_str(raw).map_err(|_| "invalid_agc_cursor")?;
|
|
if cursor.filter_key != agc_tracking_filter_key(query) || !uuid(&cursor.event_id) {
|
|
return Err("invalid_agc_cursor");
|
|
}
|
|
Ok(Some(cursor))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
const ID: &str = "790a1275-a0a0-405c-8bfd-287201bef10a";
|
|
fn event(name: &str, properties: Value) -> Event {
|
|
serde_json::from_value(json!({
|
|
"schema_version": 1, "event_id": ID, "event_name": name,
|
|
"event_time": "2026-09-21T12:00:00.000Z", "user_id": "user-a",
|
|
"editor_session_id": ID, "project_id": null, "creative_task_id": null,
|
|
"agent_run_id": null, "agent_turn_id": null, "status": "success",
|
|
"error_code": null, "source": "editor", "client_version": "1.0.0", "properties": properties
|
|
})).unwrap()
|
|
}
|
|
fn batch(event: Event) -> AgcAnalyticsBatch {
|
|
AgcAnalyticsBatch {
|
|
schema_version: 1,
|
|
batch_id: ID.into(),
|
|
destination_origin: "https://example.com".into(),
|
|
user_id: "user-a".into(),
|
|
events: vec![event],
|
|
}
|
|
}
|
|
#[test]
|
|
fn agc_accepts_twelve_existing_event_contracts() {
|
|
let cases = [
|
|
(
|
|
"editor_session_start",
|
|
json!({"entry_source":"direct_launch","first_project_id":null}),
|
|
),
|
|
(
|
|
"editor_session_end",
|
|
json!({"end_reason":"user_exit","session_duration_ms":null,"last_project_id":null}),
|
|
),
|
|
(
|
|
"editor_focus_start",
|
|
json!({"focus_interval_id":ID,"focus_reason":"window_focus","active_project_id":null}),
|
|
),
|
|
(
|
|
"editor_focus_end",
|
|
json!({"focus_interval_id":ID,"blur_reason":"window_blur","focus_duration_ms":1,"active_project_id":null}),
|
|
),
|
|
(
|
|
"project_create_success",
|
|
json!({"creation_source":"home_game"}),
|
|
),
|
|
(
|
|
"project_open",
|
|
json!({"open_source":"recent","is_first_open":null}),
|
|
),
|
|
("creative_task_submit", json!({})),
|
|
(
|
|
"agent_run_completed",
|
|
json!({"agent_type":"game_agent","run_source":"user_submit","duration_ms":1,"retry_index":0,"output_change_detected":null,"end_reason":"waiting_for_approval"}),
|
|
),
|
|
(
|
|
"agent_run_failed",
|
|
json!({"agent_type":"game_agent","run_source":"user_submit","duration_ms":1,"retry_index":0,"output_change_detected":true,"end_reason":"failed"}),
|
|
),
|
|
(
|
|
"project_revision_created",
|
|
json!({"revision_id":"design:session:phase","revision_source":"agent","change_kind":"design_document"}),
|
|
),
|
|
(
|
|
"preview_ready",
|
|
json!({"preview_source":"user","preview_version":"version1"}),
|
|
),
|
|
("project_save", json!({"save_source":"checkpoint"})),
|
|
];
|
|
for (index, (name, properties)) in cases.into_iter().enumerate() {
|
|
let mut e = event(name, properties);
|
|
if index == 2 || index == 3 {
|
|
e.status = None;
|
|
}
|
|
if index >= 4 {
|
|
e.project_id = Some("project-a".into());
|
|
}
|
|
if index >= 6 {
|
|
e.creative_task_id = e.project_id.clone();
|
|
e.source = Source::Direct;
|
|
}
|
|
if index == 7 || index == 8 {
|
|
e.agent_run_id = Some(ID.into());
|
|
}
|
|
if index == 8 {
|
|
e.status = Some(Status::Failed);
|
|
e.error_code = Some(ErrorCode::ProviderTimeout);
|
|
}
|
|
assert_eq!(validate_agc_analytics_batch(&batch(e)), Ok(()), "{name}");
|
|
}
|
|
}
|
|
#[test]
|
|
fn agc_rejects_identity_duplicate_and_unknown_content() {
|
|
let e = event(
|
|
"editor_session_start",
|
|
json!({"entry_source":"direct_launch","first_project_id":null}),
|
|
);
|
|
let mut b = batch(e.clone());
|
|
b.user_id = "other-user".into();
|
|
assert_eq!(validate_agc_analytics_batch(&b), Err("identity_mismatch"));
|
|
let mut b = batch(e.clone());
|
|
b.events.push(e.clone());
|
|
assert_eq!(validate_agc_analytics_batch(&b), Err("duplicate_event_id"));
|
|
let mut b = batch(e.clone());
|
|
b.events[0].properties["prompt"] = json!("should never be accepted");
|
|
assert_eq!(validate_agc_analytics_batch(&b), Err("invalid_properties"));
|
|
b.events[0].properties = json!(["direct_launch", null]);
|
|
assert_eq!(validate_agc_analytics_batch(&b), Err("invalid_properties"));
|
|
let mut raw = serde_json::to_value(e).unwrap();
|
|
raw["file_path"] = json!("secret");
|
|
assert!(serde_json::from_value::<Event>(raw).is_err());
|
|
}
|
|
#[test]
|
|
fn agc_requires_explicit_nullable_fields_and_no_optional_nulls() {
|
|
let mut raw =
|
|
serde_json::to_value(event("project_save", json!({"save_source":"manual"}))).unwrap();
|
|
raw.as_object_mut().unwrap().remove("agent_turn_id");
|
|
assert!(serde_json::from_value::<Event>(raw).is_err());
|
|
let mut e = event(
|
|
"project_save",
|
|
json!({"save_source":"manual","revision_id":null}),
|
|
);
|
|
e.project_id = Some("p".into());
|
|
e.creative_task_id = e.project_id.clone();
|
|
assert_eq!(e.validate(), Err("invalid_optional_property"));
|
|
}
|
|
#[test]
|
|
fn agc_query_cursor_is_bound_to_filters_and_rejects_invalid_time_range() {
|
|
let mut query = shared_contracts::admin::AdminAgcTrackingEventListQuery::default();
|
|
let cursor = AgcTrackingCursor {
|
|
snapshot: 100,
|
|
// 客户端时钟可能领先;快照限制入库时间,不能限制发生时间。
|
|
event_time: 110,
|
|
event_id: ID.into(),
|
|
filter_key: agc_tracking_filter_key(&query),
|
|
};
|
|
query.cursor = Some(serde_json::to_string(&cursor).unwrap());
|
|
assert!(validate_agc_tracking_query(&query).is_ok());
|
|
query.user_id = Some("changed-user".into());
|
|
assert_eq!(
|
|
validate_agc_tracking_query(&query).unwrap_err(),
|
|
"invalid_agc_cursor"
|
|
);
|
|
query.cursor = None;
|
|
query.start_time = Some("2026-09-22T00:00:00Z".into());
|
|
query.end_time = Some("2026-09-21T00:00:00Z".into());
|
|
assert_eq!(
|
|
validate_agc_tracking_query(&query).unwrap_err(),
|
|
"invalid_agc_query"
|
|
);
|
|
}
|
|
}
|