重构错误报告为 OSS 与数据库链路

移除 api-server 本地文件存储并改为内存 ZIP 单请求上传 OSS

管理员查询详情下载改读 SpacetimeDB 元数据与 OSS 对象

增加 OSS 内部读取删除能力与每日过期清理

同步更新错误报告技术方案、表目录和决策记录
This commit is contained in:
2026-09-01 18:05:56 +08:00
parent a6c8ad0cb6
commit 8b36b25f9d
7 changed files with 548 additions and 1086 deletions
@@ -7866,4 +7866,4 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 2026-09-01 追加:`application.log` 不再写结构化错误事件;Rust `app_log!` 和 WebView console 都写入普通文本 raw log,结构化事件仅保留在当前进程内,提交时才生成 ZIP 内的 `events.jsonl`
- 2026-09-01 review 收口:错误报告修复详情请求竞态、下载 anchor 生命周期、客户端采集脱敏/指纹降级与 4xx 噪声、用户级幂等隔离、`agc` 私有 OSS 前缀越权、日志读取链接检查、ZIP 同名日志和元数据/归档清理一致性;同步在 `review.txt` 标注仍需产品/运维决定的架构项。
- 2026-09-01 追加:api-server 按单实例部署,错误报告 store 保留进程内 Mutex 和同步本地文件 I/O,不引入跨进程锁;ZIP 仅在构建/上传阶段短暂驻留受 20 MiB 上限约束的内存 Vec,随后写入私有本地归档。管理员详情路由不属于 External OpenAPI;不存在返回 404,元数据/ZIP 损坏返回 500。
- 2026-09-01 追加:错误报告元数据由 `error-reports/index.json` 索引,管理员列表使用 `limit/offset` 并返回 `total/hasMore`;索引缺失时从现有元数据一次性重建。OSS key 固定为 `agc/error-reports/v1/{batchId}.zip`,不含日期;相同 submission 对已 `ready` 报告直接返回。归档缺少 `events.jsonl` 视为损坏,管理员更新不存在目标返回 404
- 2026-09-01 追加:错误报告不落本地文件;请求内存构建 ZIP 后直接上传 OSS,成功后写入 SpacetimeDB `error_report` 元数据。OSS key 固定为 `agc/error-reports/v1/{batchId}.zip`,不含日期;同一用户 `userId + submissionId` 幂等。管理员查询 DB,详情/下载按 object key 读取 OSS;每日清理先删 OSS,再删 DB,失败留待下次重试
@@ -18,14 +18,15 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事
- 登录态客户端使用 `POST /api/error-reports`,请求 DTO 位于 `shared-contracts::error_reports`
- api-server 对请求体设置 24 MiB 上限,并校验 schemaVersion、submissionId、事件/日志数量和 20 MiB 压缩包上限;事件字段、用户说明和日志名/内容均做长度限制与基础脱敏,归档使用 `events.jsonl`(每行一个事件)。结构化事件只保存在当前进程内,用户提交时才生成 `events.jsonl`,不在磁盘单独持久化。submissionId 提供重放幂等。
- 归档构建会短暂使用一个受 20 MiB 上限约束的内存 `Vec<u8>`,随后立即写入私有本地归档;不把 ZIP 长期留在内存。这样既控制峰值,又支持进程重启后后台查看/下载和 OSS 上传失败后的本地取证
- 本地 `error-reports/index.json` 保存元数据索引;创建时用索引完成 submission 幂等查找,管理员列表从索引读取并分页。索引缺失时会从现有元数据文件一次性重建
- 归档对象使用固定私有 OSS key:`agc/error-reports/v1/{batchId}.zip`;key 只由报告 UUID 决定,不包含时间戳。api-server 先写 `uploading` 元数据,上传成功后记录 `ossObjectKey`、SHA-256、大小和 `ready` 状态;相同 submission 重放对已 `ready` 报告直接返回,不重复上传。完整事件、说明和日志不进入元数据记录。
- 归档构建只在请求生命周期内使用受 20 MiB 上限约束的内存 `Vec<u8>`,随后直接 PUT 到私有 OSS;服务端不写本地报告文件,也不保留本地索引。OSS 上传失败不写入数据库,调用方可稍后重新提交
- 归档对象使用固定私有 OSS key`agc/error-reports/v1/{batchId}.zip`;key 只由报告 UUID 决定,不包含时间戳。上传成功后才写入 SpacetimeDB `error_report` 元数据表;`userId + submissionId` 由唯一幂等键保证重放返回已有记录。完整事件、说明和日志只存在 OSS ZIP
- `agc` 是服务端专用私有前缀;公共直传票据、通用 object-key 规范化和 legacy 公开路径均拒绝该前缀。归档内同名日志会自动加数字后缀,读取本机诊断日志时拒绝符号链接/非普通文件。
- 后台接口:`GET/PATCH /admin/api/error-reports/{batchId}``GET /admin/api/error-reports` 和受保护的 `/download`。列表支持 `limit`/`offset` 分页并返回 `total``hasMore`
- 这些是 api-server 内部登录/管理员路由,不属于 `/api/external/v1`,不纳入 External OpenAPI;管理员详情对不存在返回 404,对归档/元数据损坏返回 500。
- admin viewer 仅接受 error-reports Tab 权限,支持列表筛选、分页、详情、状态 `new/in-progress/resolved`、处理备注和受控下载;不存在的更新目标返回 404,存储损坏返回 500。列表行支持键盘 Enter/Space 打开详情,详情事件预览最多显示 20 条,完整内容通过诊断包下载获取。
- 当前兼容实现仍在 api-server 配置目录旁保留元数据与本地归档副本,便于无 OSS 配置的开发环境运行;生产配置启用 OSS 后以 OSS 对象为完整内容来源。SpacetimeDB `error_report` 私有表接入及 30 天 OSS/元数据清理 worker 为后续门禁,HTTP DTO 与管理员权限保持不变
- 管理员列表、筛选、状态和备注全部读取/更新 SpacetimeDB;详情先读表再从 OSS 下载并解析 ZIP,下载接口直接从 OSS 返回 ZIP。无需新增管理员 DELETE HTTP 接口。每日清理任务删除过期 OSS 对象(成功或对象不存在后再删 DB;失败保留 DB 供下次重试)。旧本地报告不迁移
SpacetimeDB `error_report` 表字段:`batch_id` 主键、`user_id``submission_id``idempotency_key` 唯一键、`object_key``archive_sha256``archive_size_bytes``event_count``log_count`、首个 fingerprint/source、`review_status``admin_note``created_at``updated_at`;索引为 `(user_id, submission_id)``created_at``review_status`
## 验收
@@ -633,6 +633,13 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 说明:外部 OpenAPI 调用使用的账号级 API Key 凭据表,只保存 key prefix、SHA-256 hash、作用域、撤销状态和使用时间;明文 Key 只在 `/api/profile/api-keys` 创建接口返回一次,不进入 SpacetimeDB,且 API Key 管理接口不写入外部 OpenAPI JSON。v1 默认作用域为 `editor:project``editor:canvas``editor:image-generate``editor:asset`;其中 `editor:project` 覆盖项目列表、最近项目、创建、读取、重命名和删除,`editor:canvas` 覆盖默认画布布局保存,`editor:image-generate` 覆盖编辑器现有图片生成、重绘 / 调整、去背景、规范图、宣发素材、图标 spritesheet 生成 / 拆分、UI 设计图素材拆分、角色动画、视频、音效和背景音乐生成,`editor:asset` 覆盖素材直传凭证、素材对象确认、签名读取、账号级素材库和项目画布资源记录操作。
- 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。
### `error_report`
- Rust 结构体:`ErrorReport`
- 源码:`server-rs/crates/spacetime-module/src/error_report.rs`
- 说明:错误报告只在请求内存中构建 ZIP 并上传私有 OSSSpacetimeDB 仅保存 batch、幂等、对象键、摘要、计数和管理员审核元数据。管理员列表/筛选/更新走 `spacetime-client` facade,详情和下载再从 OSS 读取 ZIP。OSS key 固定为 `agc/error-reports/v1/{batchId}.zip`,不含时间戳;上传失败不写表。
- 索引:`by_error_report_user_submission` 用于用户提交幂等;`by_error_report_created_at``by_error_report_review_status` 用于后台查询与清理。
### `admin_account`
- Rust 结构体:`AdminAccount`
File diff suppressed because it is too large Load Diff
+1
View File
@@ -566,6 +566,7 @@ fn spawn_common_app_state_background_workers(state: &AppState) {
fn spawn_http_app_state_background_workers(state: &AppState, process_role: ProcessRole) {
spawn_common_app_state_background_workers(state);
crate::error_reports::spawn_cleanup_worker(state.clone());
if should_start_profile_recharge_expiration_listener(process_role) {
spawn_profile_recharge_expiration_listener(state.clone());
spawn_profile_recharge_refund_reconciliation_worker(state.clone());
-13
View File
@@ -44,7 +44,6 @@ use crate::editor_generation_config::{
EditorGenerationPricingConfig, EditorGenerationPricingError, EditorGenerationPricingStore,
EditorGenerationPricingUnit,
};
use crate::error_reports::ErrorReportStore;
use crate::tracking_outbox::TrackingOutbox;
use crate::wallet_refund_outbox::{ProfileWalletRefundOutboxWorker, WalletRefundOutbox};
use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error};
@@ -297,7 +296,6 @@ pub struct AppStateInner {
#[cfg(any())]
puzzle_gallery_cache: PuzzleGalleryCache,
tracking_outbox: Option<Arc<TrackingOutbox>>,
error_report_store: Arc<ErrorReportStore>,
wallet_refund_outbox: Option<Arc<WalletRefundOutbox>>,
profile_wallet_refund_outbox_worker: Arc<ProfileWalletRefundOutboxWorker>,
editor_generation_pricing_store: EditorGenerationPricingStore,
@@ -591,12 +589,6 @@ impl AppState {
let ai_task_service = AiTaskService::new(InMemoryAiTaskStore::default());
let spacetime_client = SpacetimeClient::new(spacetime_client_config_for_process(&config));
let tracking_outbox = TrackingOutbox::from_config(&config, spacetime_client.clone());
let error_report_store = ErrorReportStore::new(
config
.tracking_outbox_dir
.parent()
.unwrap_or_else(|| std::path::Path::new("server-rs/.data")),
);
let wallet_refund_outbox =
WalletRefundOutbox::from_config(&config, spacetime_client.clone());
let profile_wallet_refund_outbox_worker =
@@ -674,7 +666,6 @@ impl AppState {
#[cfg(any())]
puzzle_gallery_cache: PuzzleGalleryCache::new(),
tracking_outbox,
error_report_store,
wallet_refund_outbox,
profile_wallet_refund_outbox_worker,
editor_generation_pricing_store,
@@ -1312,10 +1303,6 @@ impl AppState {
self.oss_client.as_ref()
}
pub fn error_report_store(&self) -> &Arc<ErrorReportStore> {
&self.error_report_store
}
pub fn password_entry_service(&self) -> &PasswordEntryService {
&self.password_entry_service
}
+87
View File
@@ -104,6 +104,15 @@ pub struct OssSignedGetObjectUrlRequest {
pub struct OssHeadObjectRequest {
pub object_key: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssGetObjectRequest {
pub object_key: String,
pub max_bytes: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssDeleteObjectRequest {
pub object_key: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OssPutObjectRequest {
@@ -219,6 +228,8 @@ pub struct OssClient {
pub enum OssRequestOperation {
Put,
Head,
Get,
Delete,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -848,6 +859,70 @@ impl OssClient {
result
}
pub async fn get_object(
&self,
client: &reqwest::Client,
request: OssGetObjectRequest,
) -> Result<Vec<u8>, OssError> {
let key = normalize_internal_object_key(&request.object_key)?;
let target = build_object_url(&self.config.bucket, &self.config.endpoint, &key)
.map_err(|e| request_error(OssRequestOperation::Get, &e.to_string()))?;
let response = send_signed_request(
client,
&self.config,
Method::GET,
Some(&key),
target,
OssRequestOperation::Get,
)
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(OssError::ObjectNotFound(format!("OSS 对象不存在:{key}")));
}
if !response.status().is_success() {
return Err(request_status_error(
OssRequestOperation::Get,
response.status().as_u16(),
format!("OSS GET Object 失败,状态码:{}", response.status()),
));
}
let bytes = response
.bytes()
.await
.map_err(|e| request_error_from_reqwest(OssRequestOperation::Get, e))?;
if bytes.len() > request.max_bytes {
return Err(OssError::InvalidRequest("OSS 对象超过读取上限".to_string()));
}
Ok(bytes.to_vec())
}
pub async fn delete_object(
&self,
client: &reqwest::Client,
request: OssDeleteObjectRequest,
) -> Result<(), OssError> {
let key = normalize_internal_object_key(&request.object_key)?;
let target = build_object_url(&self.config.bucket, &self.config.endpoint, &key)
.map_err(|e| request_error(OssRequestOperation::Delete, &e.to_string()))?;
let response = send_signed_request(
client,
&self.config,
Method::DELETE,
Some(&key),
target,
OssRequestOperation::Delete,
)
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND || response.status().is_success() {
return Ok(());
}
Err(request_status_error(
OssRequestOperation::Delete,
response.status().as_u16(),
format!("OSS DELETE Object 失败,状态码:{}", response.status()),
))
}
// AI 生成资源默认由服务端上传 OSS,Web 端只拿签名读地址,不直接持有写权限。
pub async fn put_object(
&self,
@@ -1693,6 +1768,18 @@ fn normalize_editor_agent_messages_object_key(raw: &str) -> Result<String, OssEr
Ok(normalized)
}
fn normalize_internal_object_key(raw: &str) -> Result<String, OssError> {
let normalized = raw.trim().trim_start_matches('/').trim().to_string();
validate_object_key_segments(&normalized)?;
if normalized.starts_with("agc/error-reports/v1/") || normalized.starts_with("editor-agent/") {
Ok(normalized)
} else {
Err(OssError::InvalidRequest(
"objectKey 不属于内部对象前缀".to_string(),
))
}
}
fn validate_object_key_segments(normalized: &str) -> Result<(), OssError> {
let segments = normalized.split('/').collect::<Vec<_>>();
if segments.len() < 2 {