use crate::*; const MAX_FIELD_CHARS: usize = 512; const MAX_NOTE_CHARS: usize = 2_000; const MAX_ARCHIVE_SIZE_BYTES: u64 = 20 * 1024 * 1024; const MAX_EVENT_COUNT: u32 = 100; const MAX_LOG_COUNT: u32 = 5; const MAX_REPORTS_PER_IDENTITY_PER_HOUR: usize = 100; const REPORT_QUOTA_WINDOW_MICROS: i64 = 60 * 60 * 1_000_000; #[spacetimedb::table( accessor = error_report, index(accessor = by_error_report_user_submission, btree(columns = [user_id, submission_id])), index(accessor = by_error_report_created_at, btree(columns = [created_at])), index(accessor = by_error_report_review_status, btree(columns = [review_status])) )] #[derive(Clone)] pub struct ErrorReport { #[primary_key] pub batch_id: String, pub user_id: String, pub submission_id: String, #[unique] pub idempotency_key: String, pub object_key: String, pub archive_sha256: String, pub archive_size_bytes: u64, pub event_count: u32, pub log_count: u32, pub first_fingerprint: Option, pub first_source: Option, pub review_status: String, pub admin_note: Option, pub created_at: Timestamp, pub updated_at: Timestamp, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ErrorReportCreateInput { pub batch_id: String, pub submission_id: String, pub idempotency_key: String, pub user_id: String, pub object_key: String, pub archive_sha256: String, pub archive_size_bytes: u64, pub event_count: u32, pub log_count: u32, pub first_fingerprint: Option, pub first_source: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ErrorReportListInput { pub status: Option, pub fingerprint: Option, pub source: Option, pub limit: u32, pub offset: u32, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ErrorReportGetInput { pub batch_id: String, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ErrorReportUpdateInput { pub batch_id: String, pub status: String, pub note: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ErrorReportDeleteInput { pub batch_id: String, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ErrorReportSnapshot { pub batch_id: String, pub user_id: String, pub submission_id: String, pub idempotency_key: String, pub object_key: String, pub archive_sha256: String, pub archive_size_bytes: u64, pub event_count: u32, pub log_count: u32, pub first_fingerprint: Option, pub first_source: Option, pub review_status: String, pub admin_note: Option, pub created_at_micros: i64, pub updated_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ErrorReportProcedureResult { pub ok: bool, pub report: Option, pub reports: Vec, pub total: u64, pub error_message: Option, } fn error(message: impl Into) -> ErrorReportProcedureResult { ErrorReportProcedureResult { ok: false, report: None, reports: Vec::new(), total: 0, error_message: Some(message.into()), } } fn validate_text(value: &str, field: &str) -> Result { let value = value.trim(); if value.is_empty() || value.chars().count() > MAX_FIELD_CHARS { return Err(format!("error_report.{field} 无效")); } Ok(value.to_string()) } fn snapshot(row: &ErrorReport) -> ErrorReportSnapshot { ErrorReportSnapshot { batch_id: row.batch_id.clone(), user_id: row.user_id.clone(), submission_id: row.submission_id.clone(), idempotency_key: row.idempotency_key.clone(), object_key: row.object_key.clone(), archive_sha256: row.archive_sha256.clone(), archive_size_bytes: row.archive_size_bytes, event_count: row.event_count, log_count: row.log_count, first_fingerprint: row.first_fingerprint.clone(), first_source: row.first_source.clone(), review_status: row.review_status.clone(), admin_note: row.admin_note.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), } } #[spacetimedb::procedure] pub fn create_error_report_and_return( ctx: &mut ProcedureContext, input: ErrorReportCreateInput, ) -> ErrorReportProcedureResult { let caller = ctx.sender(); let now = ctx.timestamp; match ctx.try_with_tx(|tx| { crate::editor_project_storage::require_editor_generation_runtime_service_identity( tx, caller, )?; let batch_id = validate_text(&input.batch_id, "batch_id")?; let user_id = validate_text(&input.user_id, "user_id")?; let submission_id = validate_text(&input.submission_id, "submission_id")?; let idempotency_key = validate_text(&input.idempotency_key, "idempotency_key")?; let object_key = validate_text(&input.object_key, "object_key")?; if object_key != format!("agc/error-reports/v1/{batch_id}.zip") { return Err("error_report.object_key 格式无效".to_string()); } let archive_sha256 = validate_text(&input.archive_sha256, "archive_sha256")?; if archive_sha256.len() != 64 || !archive_sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) { return Err("error_report.archive_sha256 必须是 64 位十六进制 SHA-256".to_string()); } if input.archive_size_bytes == 0 || input.archive_size_bytes > MAX_ARCHIVE_SIZE_BYTES { return Err("error_report.archive_size_bytes 超出上限".to_string()); } if input.event_count == 0 || input.event_count > MAX_EVENT_COUNT { return Err("error_report.event_count 超出上限".to_string()); } if input.log_count > MAX_LOG_COUNT { return Err("error_report.log_count 超出上限".to_string()); } if let Some(existing) = tx .db .error_report() .by_error_report_user_submission() .filter((user_id.as_str(), submission_id.as_str())) .next() { return Ok(snapshot(&existing)); } if tx .db .error_report() .idempotency_key() .find(&idempotency_key) .is_some() { return Err("错误报告提交幂等键冲突".to_string()); } if tx.db.error_report().batch_id().find(&batch_id).is_some() { return Err("错误报告 batch_id 已存在".to_string()); } let cutoff_micros = now .to_micros_since_unix_epoch() .saturating_sub(REPORT_QUOTA_WINDOW_MICROS); let recent_count = tx .db .error_report() .iter() .filter(|row| { row.user_id == user_id && row.created_at.to_micros_since_unix_epoch() >= cutoff_micros }) .count(); if recent_count >= MAX_REPORTS_PER_IDENTITY_PER_HOUR { return Err("错误报告提交频率超出限制,请稍后再试".to_string()); } tx.db.error_report().insert(ErrorReport { batch_id: batch_id.clone(), user_id, submission_id, idempotency_key, object_key, archive_sha256, archive_size_bytes: input.archive_size_bytes, event_count: input.event_count, log_count: input.log_count, first_fingerprint: input .first_fingerprint .as_deref() .map(|value| validate_text(value, "first_fingerprint")) .transpose()?, first_source: input .first_source .as_deref() .map(|value| validate_text(value, "first_source")) .transpose()?, review_status: "new".to_string(), admin_note: None, created_at: now, updated_at: now, }); tx.db .error_report() .batch_id() .find(&batch_id) .map(|row| snapshot(&row)) .ok_or_else(|| "错误报告写入后读取失败".to_string()) }) { Ok(report) => ErrorReportProcedureResult { ok: true, report: Some(report), reports: Vec::new(), total: 1, error_message: None, }, Err(message) => error(message), } } #[spacetimedb::procedure] pub fn get_error_report_and_return( ctx: &mut ProcedureContext, input: ErrorReportGetInput, ) -> ErrorReportProcedureResult { let caller = ctx.sender(); match ctx.try_with_tx(|tx| { crate::editor_project_storage::require_editor_generation_runtime_service_identity( tx, caller, )?; tx.db .error_report() .batch_id() .find(&input.batch_id) .map(|row| snapshot(&row)) .ok_or_else(|| "错误报告不存在".to_string()) }) { Ok(report) => ErrorReportProcedureResult { ok: true, report: Some(report), reports: Vec::new(), total: 1, error_message: None, }, Err(message) => error(message), } } #[spacetimedb::procedure] pub fn list_error_reports_and_return( ctx: &mut ProcedureContext, input: ErrorReportListInput, ) -> ErrorReportProcedureResult { let caller = ctx.sender(); match ctx.try_with_tx(|tx| { crate::editor_project_storage::require_editor_generation_runtime_service_identity( tx, caller, )?; let limit = input.limit.clamp(1, 500) as usize; let offset = input.offset as usize; if input.status.is_none() && input.fingerprint.is_none() && input.source.is_none() { let mut rows = tx .db .error_report() .by_error_report_created_at() .filter(Timestamp::from_micros_since_unix_epoch(i64::MIN)..) .collect::>(); let total = rows.len() as u64; rows.reverse(); let reports = rows .into_iter() .skip(offset) .take(limit) .map(|row| snapshot(&row)) .collect::>(); return Ok((reports, total)); } let mut rows = tx .db .error_report() .iter() .filter(|row| { input .status .as_deref() .is_none_or(|v| v == row.review_status) }) .filter(|row| { input .fingerprint .as_deref() .is_none_or(|v| row.first_fingerprint.as_deref() == Some(v)) }) .filter(|row| { input .source .as_deref() .is_none_or(|v| row.first_source.as_deref() == Some(v)) }) .collect::>(); rows.sort_by(|left, right| { right .created_at .cmp(&left.created_at) .then_with(|| right.batch_id.cmp(&left.batch_id)) }); let total = rows.len() as u64; let reports = rows .into_iter() .skip(offset) .take(limit) .map(|row| snapshot(&row)) .collect::>(); Ok::<(Vec, u64), String>((reports, total)) }) { Ok((reports, total)) => ErrorReportProcedureResult { ok: true, report: None, reports, total, error_message: None, }, Err(message) => error(message), } } #[spacetimedb::procedure] pub fn update_error_report_and_return( ctx: &mut ProcedureContext, input: ErrorReportUpdateInput, ) -> ErrorReportProcedureResult { let caller = ctx.sender(); let now = ctx.timestamp; match ctx.try_with_tx(|tx| { crate::editor_project_storage::require_editor_generation_runtime_service_identity( tx, caller, )?; if !matches!(input.status.as_str(), "new" | "in-progress" | "resolved") { return Err("状态必须是 new、in-progress 或 resolved".to_string()); } let row = tx .db .error_report() .batch_id() .find(&input.batch_id) .ok_or_else(|| "错误报告不存在".to_string())?; let note = input .note .clone() .map(|value| { if value.chars().count() > MAX_NOTE_CHARS { return Err("error_report.note 超过 2000 个字符".to_string()); } Ok(value) }) .transpose()?; let updated = ErrorReport { review_status: input.status.clone(), admin_note: note, updated_at: now, ..row.clone() }; tx.db.error_report().batch_id().delete(&row.batch_id); tx.db.error_report().insert(updated.clone()); Ok(snapshot(&updated)) }) { Ok(report) => ErrorReportProcedureResult { ok: true, report: Some(report), reports: Vec::new(), total: 1, error_message: None, }, Err(message) => error(message), } } #[spacetimedb::procedure] pub fn delete_error_report_and_return( ctx: &mut ProcedureContext, input: ErrorReportDeleteInput, ) -> ErrorReportProcedureResult { let caller = ctx.sender(); match ctx.try_with_tx(|tx| { crate::editor_project_storage::require_editor_generation_runtime_service_identity( tx, caller, )?; let row = tx .db .error_report() .batch_id() .find(&input.batch_id) .ok_or_else(|| "错误报告不存在".to_string())?; let snapshot = snapshot(&row); tx.db.error_report().batch_id().delete(&row.batch_id); Ok::(snapshot) }) { Ok(report) => ErrorReportProcedureResult { ok: true, report: Some(report), reports: Vec::new(), total: 1, error_message: None, }, Err(message) => error(message), } }