补齐游戏分发结构化可观测事件

- api-server 游戏分发模块新增 package_confirmed/package_rejected/version_submitted/version_cancelled 事件,带 request_id、game_id、version_id、耗时与体积/文件数(不记录凭据、URL 与文件内容)
- 新增 review_backlog_listed(审核积压)、review_decided(审核结论与公开修订号)、game_unpublished/game_suspended(撤销传播)与 publish_switch_blocked/unavailable(开关拦截)事件
- 发行网关拒绝路径补 debug 级 release_rejected(cookie、扩展名、未公开、非公开版本)便于排查发行 4xx
- 运维文档给出可直接 grep 的事件名与关键字段,实施计划记录真实栈复跑证据
This commit is contained in:
2026-09-20 21:30:32 +08:00
parent 7ff6e46774
commit 789aef4ce5
3 changed files with 210 additions and 14 deletions
@@ -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,真实本地栈)
@@ -625,6 +625,21 @@ npm run check:release-origin-config
本地想在真实边缘语义下复验时,可以把模板渲染到 `~/data/tmp`(替换 upstream 为本地 api-server 端口、证书换成自签通配证书、监听端口换成高位端口),用 `nginx -c <渲染文件>` 起一个临时实例,再用 `curl -H 'Host: <gameId>.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` 灰度开关控制写入,不需要改代码或重启:
@@ -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::<String>();
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<Json<Value>, 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::<String>();
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("无法确认游戏发布开关,已按关闭处理"))
}
}
}