修复自查发现的门禁与体验问题:格式、超时态语义、压缩 CPU 边界
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m48s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m3s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m33s
Project CI / Frontend tests (pull_request) Successful in 3m24s
Project CI / Backend tests (pull_request) Successful in 7m29s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m55s
Project CI / Repository checks (pull_request) Successful in 6m33s
Project CI / Native shell tests (pull_request) Successful in 9m0s

- 新增 Rust 测试补齐 cargo fmt;ESLint 按仓库约定收口:常量与解析函数拆到 platformGameLoadingSurfaceModel.ts,删除 GAME_PLAY_STARTUP_TIMEOUT_MS 常量别名(测试直接用共享常量)
- 共享加载面新增 stalled:宿主判定等待不再推进时停掉进度动画并把 aria-busy 置回 false(去掉与 role=status 重复的 aria-live)
- 后台试玩超时时传入 stalled,避免「一边转圈一边说超时」
- Accept-Encoding 权重参数名改为大小写不敏感(RFC 9110 §12.5.3),GZIP;Q=0 视为拒绝并补用例
- 发行资源压缩分级:≥2 MiB 改用 level 1(zlib 端实测约 1/3 耗时、压缩比差约 3%),小文件仍用 level 6
- 文档:主规范补压缩与条件请求契约(并写明 60 秒窗口与撤销窗口不放宽)、nginx README 修正 Pingora 压缩关闭时的口径、pitfalls 补 CPU 边界与措辞修正
This commit is contained in:
2026-10-05 18:02:48 +08:00
parent f58f51052c
commit d7c4f82d7d
13 changed files with 138 additions and 69 deletions
@@ -1,5 +1,6 @@
/* @vitest-environment jsdom */
import { PLATFORM_GAME_LOADING_TIMEOUT_MS } from '@genarrative/shared/components';
import {
act,
fireEvent,
@@ -10,8 +11,6 @@ import {
} from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import { PLATFORM_GAME_LOADING_TIMEOUT_MS } from '@genarrative/shared/components';
import {
createAdminGameDistributionPreviewSession,
getAdminGameDistributionVersion,
@@ -718,6 +718,7 @@ function AdminGameDistributionReviewDetailView({
? '可以重新创建试玩会话,或稍后再试。'
: undefined
}
stalled={isPreviewTimedOut}
/>
)}
<iframe
+1 -1
View File
@@ -105,7 +105,7 @@ curl -sSI -H 'Accept-Encoding: br' \
- 现役发行入口是平台同源路径 `https://<平台域名>/games/<gameId>/`。三份常驻模板(`genarrative.conf`、`genarrative-dev-http.conf`、容器 `deploy/container/nginx.conf`)都内联同一条同源发行入口 location,把 `/games/<gameId>/` 与 `/games/<gameId>/<asset>` 转发到 `api-server` 发行网关;不再需要独立发行域名、`*.games.<域名>` 通配 DNS 或通配 TLS。
- 该 location 的正则必须整体加双引号:`location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"`。不加引号时 nginx 会把 `{32}` 当块定界符,`nginx -t` 报 `pcre2_compile() failed: missing closing parenthesis`。
- 发行入口不使用 Cookie:边缘转发前设置 `proxy_set_header Cookie ""`;`api-server` 发行网关也会拒绝带 Cookie 的请求。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单、`ETag` 与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。
- 传输编码:发行网关自己对文本类资源(HTML/JS/CSS/JSON/SVG/WASM,≥1 KiB)下发 `Content-Encoding: gzip` + `Vary: Accept-Encoding`,边缘的 gzip/Brotli 不会二次压缩(两边都以「上游已带 `Content-Encoding` 就跳过」收口)。因此直连 api-server(本地 dev 的 Vite 代理、或关闭边缘压缩的 Pingora 模式)时用户拿到的字节与生产边缘一致,不再出现「本地/未压缩边缘下引擎包原样 1.31 MiB」的落差。
- 传输编码:发行网关自己对文本类资源(HTML/JS/CSS/JSON/SVG/WASM,≥1 KiB)下发 `Content-Encoding: gzip` + `Vary: Accept-Encoding`,边缘的 gzip/Brotli 不会二次压缩(两边都以「上游已带 `Content-Encoding` 就跳过」收口),因此本地 dev(Vite 代理)与 nginx 边缘的用户口径一致,不再有「本地引擎包原样 1.31 MiB」的落差。注意 **Pingora 网关在自身压缩关闭时会移除请求里的 `Accept-Encoding`**,那种部署形态下源站压缩不生效、边缘也不压,属于网关侧口径(见 `docs/project-memory/shared-memory/pitfalls.md` 2026-10-05 条目)。
- 条件请求:命中 `If-None-Match` 时发行网关直接返回 `304`(无正文,保留 `Cache-Control` 与 `ETag`),所以 60 秒 `max-age` 之后的重复游玩只重验证、不重下整包;下架与换版仍按 60 秒窗口在新请求上生效。
- 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。
- 审核通过时 `api-server` 按 gameId 派生同源路径 `/games/<gameId>/` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。
@@ -6412,6 +6412,7 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **现象**:用户反馈「进入游玩…加载有点慢,一直黑屏体验不好」。真实栈(真实发行包 + Chromium + 4 Mbps/100 ms 模拟链路)实测:网页游玩页点击「开始游戏」后 99.3% 像素亮度 < 24 的近黑面板 + 一行 `游戏正在启动…`,游戏画面 **3298 ms** 才出现;后台审核页点「试玩当前待审版本」后 iframe 直接以 `opacity:1` 出现、区域**全白空白** 4587 ms,页面**全程没有任何加载文案**。
- **根因**:① 网页游玩页的加载态只有一行小字盖在 `#17131b` 近黑壳上,没有封面/进度/阶段文案,`ready` 判定又只看 iframe `onLoad`(文档加载完成 ≠ 游戏可玩);② 后台试玩只有 `<iframe class="admin-game-review-preview">`,没有加载态、没有超时、没有失败处理,会话过期或资源 404 会永远停在空白;③ 发行网关把 ZIP 解压后的原文直出,`phaser.min.js` 1,375,976 B 原样下发(`Content-Encoding: none`),也没有 ETag —— 4 Mbps 下光这一个文件就 ~2.7 s,且 60 秒 `max-age` 过后浏览器只能重下整包。
- **处理(现行口径)**:新增共享组件 `PlatformGameLoadingSurface`(`packages/shared`,封面/标题 + 动效进度 + 按时长推进的阶段文案 + 慢加载提示),网页游玩页与后台试玩共用;两端的加载上限统一为 `PLATFORM_GAME_LOADING_TIMEOUT_MS`(20 秒),后台超时后给出「重新创建试玩会话」。发行网关对文本类资源(HTML/JS/CSS/JSON/SVG/WASM,≥1 KiB、客户端接受 gzip)下发 `Content-Encoding: gzip` + `Vary: Accept-Encoding`,并下发按 `versionId + 资源路径` 摘要的强 ETag,命中 `If-None-Match` 返回 304(无正文)。
- **边界**:镜像/音频/视频等已是压缩格式的资源不压;后台试玩会话仍是 `no-store` 且不给 ETag;`max-age=60, must-revalidate` 与撤销窗口不变(换版/下架仍最迟 60 秒对新请求生效,304 只是在窗口之后省掉重下)。边缘 gzip/Brotli 与本层不冲突:两边都以「响应已带 `Content-Encoding` 就跳过」收口;但 Pingora 网关在自身压缩关闭时会**移除** `accept-encoding`(`normalize_accept_encoding_for_gateway_compression`),那种部署形态下源站压缩不会生效,属于网关侧口径。
- **边界**:图片/音频/视频等已是压缩格式的资源不压;后台试玩会话仍是 `no-store` 且不给 ETag;`max-age=60, must-revalidate` 与撤销窗口不变(换版/下架仍最迟 60 秒对新请求生效,304 只是在窗口之后省掉重下)。边缘 gzip/Brotli 与本层不冲突:两边都以「响应已带 `Content-Encoding` 就跳过」收口;但 Pingora 网关在自身压缩关闭时会**移除** `accept-encoding`(`normalize_accept_encoding_for_gateway_compression`),那种部署形态下源站压缩不会生效,属于网关侧口径。
- **CPU 边界**:发行网关是公开无鉴权端点,压缩按请求实时算。release 构建实测 level 6 为 1.3 MiB→10 ms、8 MiB→59 ms、64 MiB(单文件上限)→522 ms 纯 CPU,因此在 `RELEASE_COMPRESSION_FAST_ABOVE_BYTES`(2 MiB)以上改用 level 1(zlib 端实测 level 1 约为 level 6 的 1/3 耗时、压缩比只差约 3%)。若后续要再做减法,优先把压缩结果按 `(对象键, 资源路径)` 缓存,而不是放宽级别。
- **验证**:`cargo test -p api-server --bin api-server -- game_distribution`(新增 ETag 作用域、`If-None-Match` 列表/弱校验命中、`gzip;q=0` 拒绝、文本压缩与二进制/小文件不压、304 无正文、`*` 对包内缺失路径仍 404 等用例);`npx vitest run packages/shared/src/components/PlatformGameLoadingSurface.test.tsx`、`src/components/game-distribution/GameDistributionPages.test.tsx`、`apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx`;`npm run check:game-distribution-ops-rollback-e2e` 47 项通过(含压缩/Vary/ETag/304/不接受 gzip 四条新断言)。真实栈同链路复跑:`phaser.min.js` 1,375,976 B → 353,336 B(gzip,4 Mbps 下 2724 ms → 774 ms),用户端游戏画面 3298 ms → 1543 ms、后台试玩 4587 ms → 2694 ms。
- **关联**:`packages/shared/src/components/PlatformGameLoadingSurface.tsx`、`src/components/game-distribution/GamePlayPage.tsx`、`apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx`、`server-rs/crates/api-server/src/modules/game_distribution.rs`、`deploy/nginx/README.md`。
@@ -166,6 +166,7 @@
- 作者下架或管理员封禁成功后,公开列表、详情和启动 API 立即停止返回游戏;发行网关同步按游戏和版本授权拒绝新请求,原始对象不可绕过网关访问。管理员封禁禁止作者自行重发绕过;解除需管理员明确动作并重新审核。
- 建议 HTML、公开状态与启动 API 使用 `no-store`;发行静态资源的浏览器与 CDN 有效期均不超过 60 秒,禁止 `stale-while-revalidate`、`stale-if-error` 和发行 Service Worker。下架主动 purge 相关 CDN 键,60 秒作为最大缓存撤销窗口,不把 purge 成功当唯一保障。旧版本被更新替代后,新启动只用当前版;旧游戏已经载入的脚本/资源不承诺远程抹除,用户退出或刷新后按当前授权重新判断。
- 发行网关对文本类资源按 `Accept-Encoding` 下发 `Content-Encoding: gzip` 并带 `Vary: Accept-Encoding`;同一 `(versionId, 资源路径)` 的字节在版本冻结后不变,因此可以下发强 `ETag` 并在 `If-None-Match` 命中时返回 `304`(无正文,保留 `Cache-Control` 与 `ETag`)。这只是省掉 60 秒窗口之后的重复下载:`max-age=60, must-revalidate` 与上面的撤销窗口都不放宽,换版与下架仍按 60 秒窗口对新的请求生效。
- 发布所需部署依赖包括独立站点域名及通配 TLS、每游戏 host 路由、私有存储、网关 CSP/CORS/MIME、CDN TTL/purge、管理员审核运营入口和可恢复校验执行器;缺少任一项不能宣布公开上线。
- 观察上传失败、校验耗时、审核积压、发行 4xx/5xx、撤销传播时间与容量,日志按游戏/版本/操作 ID 关联,不记录 Token、完整用户文件内容或 signed URL。原始失败/撤回包建议保留 7 天后清理,公开版本和审核记录的保留周期在上线前确定;清理必须先检查引用,不能删除仍在服务的版本。
- 回滚部署时关闭新提交和新版本激活,保留当前可玩版本与状态读取;数据库迁移不以删表回滚。安全事件通过服务端关闭游戏发行权限,不依赖前端隐藏按钮。现役实现:`game-distribution:publish` 灰度开关(后台「灰度发布配置」)控制作者写入与新版本激活——灰度默认关闭,没有 gate 行或 `enabled=false` 时不允许发布;`enabled=true` 时只有白名单/灰度命中的用户能发布(`rolloutPercent=0` 且无白名单即仍然全关)。关闭期间目录、详情、版本回读、发行网关、审核队列读取、拒绝审核与安全下架都不受影响;开关读取失败按关闭处理。
@@ -3,13 +3,13 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { PlatformGameLoadingSurface } from './PlatformGameLoadingSurface';
import {
PLATFORM_GAME_LOADING_ASSET_PHASE_MS,
PLATFORM_GAME_LOADING_ENGINE_PHASE_MS,
PLATFORM_GAME_LOADING_SLOW_PHASE_MS,
PlatformGameLoadingSurface,
resolvePlatformGameLoadingMessage,
} from './PlatformGameLoadingSurface';
} from './platformGameLoadingSurfaceModel';
function readMessage() {
return screen
@@ -84,6 +84,14 @@ describe('PlatformGameLoadingSurface', () => {
expect(screen.getByRole('img', { name: '封面' })).toBeTruthy();
});
test('宿主判定停止推进时不再声明 busy,并停掉进度动画', () => {
render(<PlatformGameLoadingSurface title="待审版本试玩" stalled />);
const surface = screen.getByRole('status');
expect(surface.getAttribute('aria-busy')).toBe('false');
expect(surface.className).toContain('platform-game-loading--stalled');
});
test('没有封面时渲染图标占位而不是空白区块', () => {
const { container } = render(
<PlatformGameLoadingSurface title="加载中" tone="player" />,
@@ -1,18 +1,10 @@
import { Gamepad2 } from 'lucide-react';
import { useEffect, useState, type ReactNode } from 'react';
import { type ReactNode, useEffect, useState } from 'react';
/** 阶段文案切换点:先「准备」,再「资源」,最后「引擎」。 */
export const PLATFORM_GAME_LOADING_ASSET_PHASE_MS = 1_500;
export const PLATFORM_GAME_LOADING_ENGINE_PHASE_MS = 6_000;
/** 超过这个时长仍未进入游戏,补一行「比平时久」的状态,避免用户以为卡死。 */
export const PLATFORM_GAME_LOADING_SLOW_PHASE_MS = 12_000;
/**
* 发行包首屏加载上限。
*
* 网页游玩页与后台审核试玩共用同一口径:超过上限就给出显式超时与重试,而不是让
* 用户对着加载面无限期等待。
*/
export const PLATFORM_GAME_LOADING_TIMEOUT_MS = 20_000;
import {
PLATFORM_GAME_LOADING_SLOW_PHASE_MS,
resolvePlatformGameLoadingMessage,
} from './platformGameLoadingSurfaceModel';
export type PlatformGameLoadingSurfaceTone = 'player' | 'panel';
@@ -30,32 +22,23 @@ export type PlatformGameLoadingSurfaceProps = {
message?: string;
/** 追加一行提示(例如失败的兜底说明)。 */
hint?: string;
/**
* 宿主已判定这次等待不再推进(例如已经给出超时结论):停掉进度动画并把
* `aria-busy` 置回 `false`,避免「一边转圈一边说超时」。
*/
stalled?: boolean;
className?: string;
};
const DEFAULT_COPY_BY_PHASE: Record<'prepare' | 'assets' | 'engine', string> = {
prepare: '正在准备游戏…',
assets: '正在加载游戏资源…',
engine: '正在启动游戏引擎…',
};
/** 按已等待时长给出阶段文案;与 `PlatformGameLoadingSurface` 内的计时同源。 */
export function resolvePlatformGameLoadingMessage(elapsedMs: number) {
if (elapsedMs >= PLATFORM_GAME_LOADING_ENGINE_PHASE_MS) {
return DEFAULT_COPY_BY_PHASE.engine;
}
if (elapsedMs >= PLATFORM_GAME_LOADING_ASSET_PHASE_MS) {
return DEFAULT_COPY_BY_PHASE.assets;
}
return DEFAULT_COPY_BY_PHASE.prepare;
}
/**
* 平台通用「游戏加载面」。
*
* 发行包(Phaser/Vite 产物)首屏要下完整个引擎与资源,网络稍慢就是好几秒;这段时间
* 网页游玩页与后台审核试玩都必须给出**看得见的**加载反馈,而不是近黑面板上的一行小字
* 或一片空白。阶段文案由组件自己按时长推进,宿主只负责在真正进入游戏后把它换掉。
*
* 组件按绝对定位铺满**最近的定位祖先**:宿主必须给它一个 `position: relative` 的容器
* (网页游玩页是 `.game-player-shell`,后台试玩是 `.admin-game-review-preview-shell`)。
*/
export function PlatformGameLoadingSurface({
title,
@@ -63,6 +46,7 @@ export function PlatformGameLoadingSurface({
tone = 'panel',
message,
hint,
stalled = false,
className,
}: PlatformGameLoadingSurfaceProps) {
const [elapsedMs, setElapsedMs] = useState(0);
@@ -84,12 +68,12 @@ export function PlatformGameLoadingSurface({
return (
<div
role="status"
aria-live="polite"
aria-busy="true"
aria-busy={stalled ? 'false' : 'true'}
aria-label={`${title} ${statusMessage}`}
className={[
'platform-game-loading',
`platform-game-loading--${tone}`,
stalled ? 'platform-game-loading--stalled' : null,
className,
]
.filter(Boolean)
+2 -2
View File
@@ -48,14 +48,14 @@ export type {
PlatformGameLoadingSurfaceProps,
PlatformGameLoadingSurfaceTone,
} from './PlatformGameLoadingSurface';
export { PlatformGameLoadingSurface } from './PlatformGameLoadingSurface';
export {
PLATFORM_GAME_LOADING_ASSET_PHASE_MS,
PLATFORM_GAME_LOADING_ENGINE_PHASE_MS,
PLATFORM_GAME_LOADING_SLOW_PHASE_MS,
PLATFORM_GAME_LOADING_TIMEOUT_MS,
PlatformGameLoadingSurface,
resolvePlatformGameLoadingMessage,
} from './PlatformGameLoadingSurface';
} from './platformGameLoadingSurfaceModel';
export type {
PlatformIconBadgeProps,
PlatformIconBadgeShape,
@@ -0,0 +1,29 @@
/** 阶段文案切换点:先「准备」,再「资源」,最后「引擎」。 */
export const PLATFORM_GAME_LOADING_ASSET_PHASE_MS = 1_500;
export const PLATFORM_GAME_LOADING_ENGINE_PHASE_MS = 6_000;
/** 超过这个时长仍未进入游戏,补一行「比平时久」的状态,避免用户以为卡死。 */
export const PLATFORM_GAME_LOADING_SLOW_PHASE_MS = 12_000;
/**
* 发行包首屏加载上限。
*
* 网页游玩页与后台审核试玩共用同一口径:超过上限就给出显式超时与重试,而不是让
* 用户对着加载面无限期等待。
*/
export const PLATFORM_GAME_LOADING_TIMEOUT_MS = 20_000;
const DEFAULT_COPY_BY_PHASE: Record<'prepare' | 'assets' | 'engine', string> = {
prepare: '正在准备游戏…',
assets: '正在加载游戏资源…',
engine: '正在启动游戏引擎…',
};
/** 按已等待时长给出阶段文案;与 `PlatformGameLoadingSurface` 内的计时同源。 */
export function resolvePlatformGameLoadingMessage(elapsedMs: number) {
if (elapsedMs >= PLATFORM_GAME_LOADING_ENGINE_PHASE_MS) {
return DEFAULT_COPY_BY_PHASE.engine;
}
if (elapsedMs >= PLATFORM_GAME_LOADING_ASSET_PHASE_MS) {
return DEFAULT_COPY_BY_PHASE.assets;
}
return DEFAULT_COPY_BY_PHASE.prepare;
}
@@ -1677,6 +1677,14 @@ textarea.genarrative-ui-text-field__control {
background: var(--platform-button-primary-fill, #c7653d);
}
/* 宿主已判定这次等待不再推进(例如已经给出超时结论):进度条停下并压暗,
避免出现「一边转圈一边说超时」的自相矛盾。 */
.platform-game-loading--stalled .platform-game-loading__fill {
animation: none;
transform: none;
opacity: 0.45;
}
.platform-game-loading__hint {
margin: 0;
font-size: 0.72rem;
@@ -927,11 +927,16 @@ fn accepts_gzip_encoding(header: Option<&HeaderValue>) -> bool {
return false;
}
!parts.any(|parameter| {
parameter
.trim()
.strip_prefix("q=")
.and_then(|value| value.trim().parse::<f32>().ok())
.is_some_and(|quality| quality <= 0.0)
// 权重参数名大小写不敏感(RFC 9110 §12.5.3):`Q=0` 与 `q=0` 一样是明确拒绝。
let parameter = parameter.trim();
let Some((name, value)) = parameter.split_once('=') else {
return false;
};
name.eq_ignore_ascii_case("q")
&& value
.trim()
.parse::<f32>()
.is_ok_and(|quality| quality <= 0.0)
})
})
}
@@ -945,8 +950,21 @@ fn is_compressible_release_content_type(content_type: &str) -> bool {
|| content_type.contains("application/wasm")
}
/// 超过这个体积改用更快的压缩级别。
///
/// 单文件上限是 64 MiB,而发行网关是公开无鉴权端点:release 构建下 level 6 实测
/// 1.3 MiB→10 ms、8 MiB→59 ms、64 MiB→522 ms 纯 CPU,每请求重算会占满 worker 线程。
/// 大文件改用 level 1(zlib 端实测约为 level 6 的 1/3 耗时、压缩比只差约 3%),
/// 小文件仍用 level 6 拿更好的比例。
const RELEASE_COMPRESSION_FAST_ABOVE_BYTES: usize = 2 * 1024 * 1024;
fn gzip_release_asset(content: &[u8]) -> Option<Vec<u8>> {
let mut encoder = GzEncoder::new(Vec::new(), GzipCompression::new(6));
let level = if content.len() >= RELEASE_COMPRESSION_FAST_ABOVE_BYTES {
GzipCompression::fast()
} else {
GzipCompression::new(6)
};
let mut encoder = GzEncoder::new(Vec::new(), level);
encoder.write_all(content).ok()?;
encoder.finish().ok()
}
@@ -4933,8 +4951,14 @@ mod tests {
#[test]
fn release_asset_etag_is_scoped_to_version_and_path() {
let etag = release_asset_etag("version_1", "assets/phaser.min.js");
assert_eq!(etag, release_asset_etag("version_1", "assets/phaser.min.js"));
assert_ne!(etag, release_asset_etag("version_2", "assets/phaser.min.js"));
assert_eq!(
etag,
release_asset_etag("version_1", "assets/phaser.min.js")
);
assert_ne!(
etag,
release_asset_etag("version_2", "assets/phaser.min.js")
);
assert_ne!(etag, release_asset_etag("version_1", "assets/game.js"));
assert!(etag.starts_with('"'), "ETag 必须是带引号的实体标记:{etag}");
}
@@ -4942,7 +4966,13 @@ mod tests {
#[test]
fn release_asset_conditional_request_matches_lists_and_weak_validators() {
let etag = "\"abc\"";
for accepted in ["*", "\"abc\"", "W/\"abc\"", "\"zzz\", \"abc\"", " W/\"abc\" "] {
for accepted in [
"*",
"\"abc\"",
"W/\"abc\"",
"\"zzz\", \"abc\"",
" W/\"abc\" ",
] {
assert!(
if_none_match_matches(
Some(&HeaderValue::from_str(accepted).expect("header")),
@@ -4967,17 +4997,21 @@ mod tests {
fn release_asset_gzip_acceptance_honours_quality_zero() {
for accepted in ["gzip", "GZIP", "*", "br, gzip;q=0.8", "deflate, gzip"] {
assert!(
accepts_gzip_encoding(Some(
&HeaderValue::from_str(accepted).expect("header")
)),
accepts_gzip_encoding(Some(&HeaderValue::from_str(accepted).expect("header"))),
"应接受 gzip:{accepted}"
);
}
for rejected in ["", "br", "gzip;q=0", "*;q=0.0", "identity"] {
for rejected in [
"",
"br",
"gzip;q=0",
"*;q=0.0",
"identity",
"gzip;Q=0",
"GZIP;Q=0.000",
] {
assert!(
!accepts_gzip_encoding(Some(
&HeaderValue::from_str(rejected).expect("header")
)),
!accepts_gzip_encoding(Some(&HeaderValue::from_str(rejected).expect("header"))),
"不应接受 gzip:{rejected}"
);
}
@@ -5125,9 +5159,15 @@ mod tests {
},
)
.expect("首次响应");
assert_eq!(served.headers().get(header::ETAG).expect("ETag"), etag.as_str());
assert_eq!(
served.headers().get(header::CACHE_CONTROL).expect("缓存策略"),
served.headers().get(header::ETAG).expect("ETag"),
etag.as_str()
);
assert_eq!(
served
.headers()
.get(header::CACHE_CONTROL)
.expect("缓存策略"),
cache_control
);
@@ -5145,7 +5185,10 @@ mod tests {
.expect("条件请求");
assert_eq!(not_modified.status(), StatusCode::NOT_MODIFIED);
assert_eq!(
not_modified.headers().get(header::CACHE_CONTROL).expect("缓存策略"),
not_modified
.headers()
.get(header::CACHE_CONTROL)
.expect("缓存策略"),
cache_control
);
assert_eq!(
@@ -1,5 +1,6 @@
/* @vitest-environment jsdom */
import { PLATFORM_GAME_LOADING_TIMEOUT_MS } from '@genarrative/shared/components';
import {
act,
cleanup,
@@ -19,7 +20,7 @@ import type {
} from '../../services/gameDistributionClient';
import { GameDetailPage } from './GameDetailPage';
import { GameGalleryPage } from './GameGalleryPage';
import { GAME_PLAY_STARTUP_TIMEOUT_MS, GamePlayPage } from './GamePlayPage';
import { GamePlayPage } from './GamePlayPage';
import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel';
const listGamesMock = vi.hoisted(() => vi.fn());
@@ -712,7 +713,7 @@ describe('GamePlayPage 启动超时与旋转提示', () => {
const firstFrame = screen.getByTitle('星轨防线 在线游玩');
act(() => {
vi.advanceTimersByTime(GAME_PLAY_STARTUP_TIMEOUT_MS);
vi.advanceTimersByTime(PLATFORM_GAME_LOADING_TIMEOUT_MS);
});
expect(screen.getByText(/启动超时/u)).toBeTruthy();
expect(screen.getByRole('button', { name: /^返回$/u })).toBeTruthy();
@@ -728,7 +729,7 @@ describe('GamePlayPage 启动超时与旋转提示', () => {
);
act(() => {
vi.advanceTimersByTime(GAME_PLAY_STARTUP_TIMEOUT_MS);
vi.advanceTimersByTime(PLATFORM_GAME_LOADING_TIMEOUT_MS);
});
expect(screen.queryByText(/启动超时/u)).toBeNull();
@@ -30,12 +30,6 @@ import { GameCover } from './GameGalleryPage';
type GamePlayPageProps = { gameId: string | null; onBack: () => void };
/**
* 发行包首屏加载上限:超过后给出显式超时与重试,不用无限期等待。
* 与后台审核试玩共用同一口径(`PLATFORM_GAME_LOADING_TIMEOUT_MS`)。
*/
export const GAME_PLAY_STARTUP_TIMEOUT_MS = PLATFORM_GAME_LOADING_TIMEOUT_MS;
type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout';
export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
@@ -100,7 +94,7 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
setStartupState((current) =>
current === 'loading' ? 'timeout' : current,
);
}, GAME_PLAY_STARTUP_TIMEOUT_MS);
}, PLATFORM_GAME_LOADING_TIMEOUT_MS);
return () => window.clearTimeout(timeoutId);
}, [hasStarted, startupState]);