补齐游戏分发结构化可观测事件
- 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:
@@ -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("无法确认游戏发布开关,已按关闭处理"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user