diff --git a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md index abbc8d0c3..712e9cae8 100644 --- a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md +++ b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md @@ -56,6 +56,7 @@ - 上传中断与进程重启恢复(真实栈,两次独立实验):60 MiB 包 PUT 进行到 ~2.5s 时 `SIGKILL` api-server,客户端拿到 `RemoteDisconnected`(等价于响应丢失);重启后该版本仍是 `awaiting_upload`/`upload`,没有半确认状态。① 用不同字节重传返回 422 `PACKAGE_VALIDATION_FAILED`(`InvalidArchive`)并转 `upload_failed`/`reupload`;② 用固定时间戳构造的确定性 60 MiB 包重传**相同字节**返回 200(7.64s),版本转 `uploaded`/`submit`,随后送审 202 进入 `pending_review`。两次实验中游戏都未出现在公开目录。 - 发布开关(回滚/事故能力):新增 `game-distribution:publish` 灰度 gate(复用现役灰度配置机制与后台入口)。默认开放;`enabled=true` 时按白名单/用户标签/灰度百分比放行,`rolloutPercent=0` 且无白名单即全部关闭。关闭时作者写入与管理员批准返回 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`,而目录、详情、版本回读、发行网关、`/my-games`、审核队列读取、拒绝审核与安全下架继续可用;开关读取失败按关闭处理。路由级测试覆盖「默认放行 / 全关拦截 / 读取不受影响 / 白名单放行」,真实栈验证同口径:关闭后作者写入 503、目录/详情/网关/my-games 全 200、管理员批准 503 而拒绝 200、白名单用户可发布、重新开放后恢复。 - 游戏内真实触屏输入(本机 Chromium + CDP `Input.dispatchTouchEvent`,390×844 视口):在 `/games/play` 点「开始游戏」后,向 iframe 可视区域坐标派发 `touchStart`/`touchEnd`;`sandbox="allow-scripts"` 且 `ready` 的沙箱内探测包回传 `{touchstart: 1, touchend: 1, pointerdown: 1, pointerTypes: ["touch"], click: 1, target: "pad", touches: 1}`,证明真实触摸事件经平台进入游戏容器并命中目标元素。注意:触摸模拟必须在 iframe 文档创建之前启用,否则文档不会注册 touch 事件支持(首次实测只收到 pointerdown,重载后 touchstart/touchend 才出现)。 +- 可观测性(阶段 D「上传失败、校验耗时、审核积压、撤销传播」):`api-server` 的游戏分发模块补齐结构化事件,均带 `request_id`(可 join 访问日志)与 `operation`,不记录 Token、signed URL、完整文件内容或本地路径。事件与字段:`package_confirmed`(game_id/version_id/package_bytes/file_count/sha256_prefix/oss_put_skipped/elapsed_ms)、`package_rejected`(code/reason/uploaded_bytes/elapsed_ms)、`version_submitted`、`version_cancelled`、`review_backlog_listed`(pending_versions/limit)、`review_decided`(decision/admin_user_id/publication_revision)、`game_unpublished`(visibility/publication_revision/active_version_id)、`game_suspended`(admin/reason/publication_revision)、`publish_switch_blocked`/`publish_switch_unavailable`、发行网关 `release_rejected`(debug 级:reason=cookie_present/unsupported_extension/not_public/no_active_version/version_not_published)。真实栈复跑一次完整链路后,8 类事件各出现 1-2 次,字段与耗时均可用(样例:`package_rejected … reason=InvalidArchive uploaded_bytes=9 elapsed_ms=6`、`game_unpublished … visibility=unpublished publication_revision=2 active_version_id=""`)。 - 工程门禁:`npm run check:spacetime-schema`(147 tables)、`npm run check:server-rs-ddd`、`cargo fmt --all -- --check`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 ## 运行时证据(2026-09-20,真实本地栈) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 208fa3c55..bfbfd04e8 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -625,6 +625,21 @@ npm run check:release-origin-config 本地想在真实边缘语义下复验时,可以把模板渲染到 `~/data/tmp`(替换 upstream 为本地 api-server 端口、证书换成自签通配证书、监听端口换成高位端口),用 `nginx -c <渲染文件>` 起一个临时实例,再用 `curl -H 'Host: .games.example.com'` 验证:根路径 200 `text/html`、`/assets/*` 200、带 Cookie 403、平台 API 路径 404、未知 gameId 404、http 301 到 https。 +#### 游戏分发可观测事件 + +`api-server` 的游戏分发日志都带 `request_id`(与访问日志同一值)和 `operation`,可直接按游戏/版本关联;不记录 Token、signed URL、文件内容或本地路径。 + +```bash +# 上传与审核链路 +journalctl -u genarrative-api -o cat | grep -E 'operation="(package_confirmed|package_rejected|version_submitted|version_cancelled|review_decided|review_backlog_listed|game_unpublished|game_suspended)"' +# 发布开关是否在拦流量 +journalctl -u genarrative-api -o cat | grep -E 'operation="publish_switch_(blocked|unavailable)"' +# 发行网关 4xx 原因(需要 debug 级日志) +journalctl -u genarrative-api -o cat | grep 'operation="release_rejected"' +``` + +关键字段:`package_confirmed.elapsed_ms` 与 `package_bytes`(校验耗时/容量)、`package_rejected.code|reason`(失败原因)、`review_backlog_listed.pending_versions`(审核积压)、`game_unpublished|game_suspended.publication_revision`(撤销传播时可与详情回读对账)。 + #### 发布开关(关闭投稿、保在线) 发布事故或回滚窗口里用 `game-distribution:publish` 灰度开关控制写入,不需要改代码或重启: diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 660459817..6843c998f 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -33,6 +33,7 @@ use spacetime_client::{ GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput, GameDistributionVersionRecord, SpacetimeClientError, }; +use tracing::{debug, info, warn}; use uuid::Uuid; use crate::{ @@ -237,22 +238,61 @@ async fn serve_release_asset( // 发行文件必须由独立来源提供。带上平台 Cookie 的请求说明它正落在主站来源上, // 此时同源脚本可以读到平台会话,必须直接关闭而不是降级服务。 if headers.contains_key(header::COOKIE) { + debug!( + operation = "release_rejected", + game_id = %game_id, + reason = "cookie_present", + "发行资源请求带平台 Cookie,已拒绝" + ); return Err(AppError::from_status(StatusCode::FORBIDDEN) .with_message("发行资源必须在独立来源上请求")); } let asset_path = asset_path.trim_start_matches('/').to_string(); - let content_type = release_asset_content_type(&asset_path) - .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; + let content_type = release_asset_content_type(&asset_path).ok_or_else(|| { + debug!( + operation = "release_rejected", + game_id = %game_id, + asset_path = %asset_path, + reason = "unsupported_extension", + "发行资源扩展名不在白名单内" + ); + AppError::from_status(StatusCode::NOT_FOUND) + })?; let public_game = state .spacetime_client() .get_public_game_distribution_game(game_id.clone()) .await .map_err(map_spacetime_error)? - .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; - let version = public_game - .current_version - .ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?; + .ok_or_else(|| { + debug!( + operation = "release_rejected", + game_id = %game_id, + asset_path = %asset_path, + reason = "not_public", + "游戏没有公开可玩版本" + ); + AppError::from_status(StatusCode::NOT_FOUND) + })?; + let version = public_game.current_version.ok_or_else(|| { + debug!( + operation = "release_rejected", + game_id = %game_id, + asset_path = %asset_path, + reason = "no_active_version", + "游戏缺少当前公开版本" + ); + AppError::from_status(StatusCode::NOT_FOUND) + })?; if version.status != GAME_DISTRIBUTION_PUBLISHED_STATUS { + debug!( + operation = "release_rejected", + game_id = %game_id, + version_id = %version.version_id, + asset_path = %asset_path, + reason = "version_not_published", + status = %version.status, + "请求的版本不是公开状态" + ); return Err(AppError::from_status(StatusCode::NOT_FOUND)); } let package = release_package_bytes(&state, &game_id, &version.version_id).await?; @@ -531,14 +571,26 @@ async fn upload_package( let manifest = match validate_release_zip(&body) { Ok(manifest) => manifest, Err(error) => { - let mapped = map_package_error(error.clone()); + let reason = format!("{error:?}"); + warn!( + request_id = ctx.request_id(), + operation = "package_rejected", + game_id = %expected.game_id, + version_id = %version_id, + code = "PACKAGE_VALIDATION_FAILED", + reason = %reason, + uploaded_bytes = body.len(), + elapsed_ms = ctx.elapsed(), + "发行包校验失败" + ); + let mapped = map_package_error(error); record_upload_failure( &state, &owner_user_id, &version_id, &idempotency_key, "PACKAGE_VALIDATION_FAILED", - format!("{error:?}"), + reason, ) .await; return Err(mapped); @@ -549,6 +601,19 @@ async fn upload_package( || u32::try_from(manifest.files.len()).unwrap_or(u32::MAX) != expected.package_file_count || expected.package_entry_path != "index.html" { + warn!( + request_id = ctx.request_id(), + operation = "package_rejected", + game_id = %expected.game_id, + version_id = %version_id, + code = "PACKAGE_MISMATCH", + declared_bytes = expected.package_bytes, + actual_bytes = manifest.package_bytes, + declared_file_count = expected.package_file_count, + actual_file_count = u32::try_from(manifest.files.len()).unwrap_or(u32::MAX), + elapsed_ms = ctx.elapsed(), + "发行包与版本声明不一致" + ); let error = AppError::from_status(StatusCode::CONFLICT) .with_code("PACKAGE_MISMATCH") .with_details(json!({ @@ -619,6 +684,11 @@ async fn upload_package( &serde_json::to_vec(&(version_id.as_str(), manifest.package_sha256.as_str())) .map_err(|error| internal(error.to_string()))?, ); + let log_game_id = expected.game_id.clone(); + let log_package_bytes = manifest.package_bytes; + let log_file_count = u32::try_from(manifest.files.len()).unwrap_or(u32::MAX); + let log_sha_prefix = manifest.package_sha256.chars().take(12).collect::(); + let log_oss_put_skipped = skipped; let confirmed = state .spacetime_client() .confirm_game_distribution_package( @@ -638,6 +708,18 @@ async fn upload_package( ) .await .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "package_confirmed", + game_id = %log_game_id, + version_id = %confirmed.0.version_id, + package_bytes = log_package_bytes, + file_count = log_file_count, + sha256_prefix = %log_sha_prefix, + oss_put_skipped = log_oss_put_skipped, + elapsed_ms = ctx.elapsed(), + "发行包已确认" + ); Ok(json_success_body( Some(&ctx), json!({ "versionId": confirmed.0.version_id, "status": confirmed.0.status }), @@ -674,6 +756,9 @@ async fn submit_version( &serde_json::to_vec(&(version_id.as_str(), payload.expected_publication_revision)) .map_err(|error| internal(error.to_string()))?, ); + let log_game_id = version.game_id.clone(); + let log_version_number = version.version_number; + let log_revision = payload.expected_publication_revision; let submitted = state .spacetime_client() .submit_game_distribution_version_for_review(GameDistributionSubmitReviewRecordInput { @@ -686,6 +771,17 @@ async fn submit_version( }) .await .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "version_submitted", + game_id = %log_game_id, + version_id = %submitted.0.version_id, + version_number = log_version_number, + publication_revision = log_revision, + replayed = submitted.1, + elapsed_ms = ctx.elapsed(), + "版本已送审" + ); Ok(( StatusCode::ACCEPTED, json_success_body( @@ -785,6 +881,16 @@ async fn cancel_version( }) .await .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "version_cancelled", + game_id = %version.game_id, + version_id = %version.version_id, + version_number = version.version_number, + replayed, + elapsed_ms = ctx.elapsed(), + "版本已撤回" + ); Ok(json_success_body( Some(&ctx), json!({ @@ -805,6 +911,8 @@ async fn unpublish_game( ) -> Result, AppError> { let owner_user_id = auth.claims().user_id().to_string(); ensure_publish_enabled(&state, Some(owner_user_id.as_str())).await?; + let log_expected_revision = payload.expected_publication_revision; + let log_game_id = game_id.clone(); let idempotency_key = idempotency_key(&headers)?; let request_digest = compute_request_digest( &serde_json::to_vec(&(game_id.as_str(), payload.expected_publication_revision)) @@ -822,6 +930,18 @@ async fn unpublish_game( }) .await .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "game_unpublished", + game_id = %log_game_id, + expected_publication_revision = log_expected_revision, + visibility = %game.0.visibility, + publication_revision = game.0.publication_revision, + active_version_id = game.0.active_version_id.as_deref().unwrap_or(""), + replayed = game.1, + elapsed_ms = ctx.elapsed(), + "作者下架游戏,公开入口已关闭" + ); Ok(json_success_body( Some(&ctx), json!({ "game": game_payload(&game.0), "replayed": game.1 }), @@ -840,6 +960,14 @@ async fn admin_list_reviews( .list_game_distribution_reviews(limit) .await .map_err(map_spacetime_error)?; + info!( + request_id = ctx.request_id(), + operation = "review_backlog_listed", + pending_versions = reviews.len(), + limit, + elapsed_ms = ctx.elapsed(), + "后台读取待审发行版本" + ); Ok(json_success_body( Some(&ctx), json!({ @@ -863,6 +991,7 @@ async fn admin_review_version( return Err(bad_request("审核结论必须是 approve 或 reject")); } let admin_user_id = admin.session().subject.clone(); + let log_admin_user_id = admin_user_id.clone(); let request_digest = compute_request_digest( &serde_json::to_vec(&( version_id.as_str(), @@ -913,6 +1042,18 @@ async fn admin_review_version( .await .map_err(map_spacetime_error)? }; + info!( + request_id = ctx.request_id(), + operation = "review_decided", + version_id = %version.version_id, + game_id = %version.game_id, + decision = %decision, + admin_user_id = %log_admin_user_id, + publication_revision = version.publication_revision, + replayed, + elapsed_ms = ctx.elapsed(), + "管理员完成发行版本审核" + ); Ok(json_success_body( Some(&ctx), json!({ "version": private_version_payload(&version), "replayed": replayed }), @@ -995,6 +1136,17 @@ async fn admin_suspend_game( )) .map_err(|error| internal(error.to_string()))?, ); + let log_game_id = game_id.clone(); + let log_admin_user_id = admin_user_id.clone(); + let log_expected_revision = payload.expected_publication_revision; + let log_reason = payload + .reason + .as_deref() + .map(str::trim) + .unwrap_or("") + .chars() + .take(120) + .collect::(); let game = state .spacetime_client() .suspend_game_distribution_game(GameDistributionSuspendRecordInput { @@ -1008,6 +1160,19 @@ async fn admin_suspend_game( }) .await .map_err(map_spacetime_error)?; + warn!( + request_id = ctx.request_id(), + operation = "game_suspended", + game_id = %log_game_id, + admin_user_id = %log_admin_user_id, + expected_publication_revision = log_expected_revision, + publication_revision = game.0.publication_revision, + visibility = %game.0.visibility, + reason = %log_reason, + replayed = game.1, + elapsed_ms = ctx.elapsed(), + "管理员安全下架游戏" + ); Ok(json_success_body( Some(&ctx), json!({ "game": game_payload(&game.0), "replayed": game.1 }), @@ -1227,12 +1392,27 @@ async fn ensure_publish_enabled(state: &AppState, user_id: Option<&str>) -> Resu .await { Ok(true) => Ok(()), - Ok(false) => Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) - .with_code("GAME_DISTRIBUTION_PUBLISH_DISABLED") - .with_message("游戏发布暂已关闭,已公开游戏仍可继续游玩")), - Err(_) => Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) - .with_code("GAME_DISTRIBUTION_PUBLISH_DISABLED") - .with_message("无法确认游戏发布开关,已按关闭处理")), + Ok(false) => { + warn!( + operation = "publish_switch_blocked", + user_id = user_id.unwrap_or(""), + "游戏发布开关已收紧,写入被拦截" + ); + Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_code("GAME_DISTRIBUTION_PUBLISH_DISABLED") + .with_message("游戏发布暂已关闭,已公开游戏仍可继续游玩")) + } + Err(error) => { + warn!( + operation = "publish_switch_unavailable", + user_id = user_id.unwrap_or(""), + error = %error, + "无法读取游戏发布开关,按关闭处理" + ); + Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_code("GAME_DISTRIBUTION_PUBLISH_DISABLED") + .with_message("无法确认游戏发布开关,已按关闭处理")) + } } }