修复自查发现的门禁与体验问题:格式、超时态语义、压缩 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
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user