Merge pull request '【付费游玩】游戏买断制泥点付费规范:主规范合同、里程碑与实施计划(#634)' (#635) from feat/game-purchase into master
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled

Reviewed-on: http://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/635
This commit was merged in pull request #635.
This commit is contained in:
2026-10-06 15:16:00 +08:00
287 changed files with 10588 additions and 1929 deletions
+4
View File
@@ -1183,6 +1183,8 @@ export interface AdminGameDistributionReviewEntry {
packageBytes: number;
status: string;
publicationRevision: number;
/** 买断制泥点价格;0 表示免费。后端始终下发,旧响应可能缺该字段,读取方按 0 兜底。 */
priceMudPoints?: number;
reviewReason: string | null;
createdAt: string;
updatedAt: string;
@@ -1211,6 +1213,8 @@ export interface AdminGameDistributionGameDetail {
orientation: string;
status: string;
publicationRevision: number;
/** 买断制泥点价格;0 表示免费。后端始终下发,旧响应可能缺该字段,读取方按 0 兜底。 */
priceMudPoints?: number;
playCount: number;
createdAt: string;
}
@@ -19,6 +19,7 @@ import {
reviewAdminGameDistributionVersion,
} from '../api/adminApiClient';
import type {
AdminGameDistributionGameDetail,
AdminGameDistributionGameListResponse,
AdminGameDistributionReviewEntry,
AdminGameDistributionVersionDetailResponse,
@@ -50,6 +51,7 @@ const entry: AdminGameDistributionReviewEntry = {
packageBytes: 2048,
status: 'pending_review',
publicationRevision: 4,
priceMudPoints: 30,
reviewReason: null,
// 真实后台下发的是 `seconds.microsZ`(非 ISO 8601),见 shared-kernel `format_timestamp_micros`。
createdAt: '1791008678.817257Z',
@@ -94,6 +96,7 @@ const detailResponse: AdminGameDistributionVersionDetailResponse = {
orientation: 'landscape',
status: 'unpublished',
publicationRevision: 7,
priceMudPoints: 30,
playCount: 0,
createdAt: '2026-09-20T08:00:00Z',
},
@@ -230,7 +233,8 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间',
const row = screen.getByText(gameTitle).closest('tr');
expect(row).not.toBeNull();
const submittedCell = within(row as HTMLElement).getAllByRole('cell')[3];
// 列序:游戏 / 版本 / 发行包 / 价格 / 提交时间 / 审核。
const submittedCell = within(row as HTMLElement).getAllByRole('cell')[4];
// 解析不出来时会回落到 `-`,这正是本次修复前的线上表现。
expect(submittedCell?.textContent).not.toBe('-');
expect(submittedCell?.textContent).toMatch(
@@ -238,6 +242,56 @@ test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间',
);
});
test('价格列按买断制口径展示,免费与缺失价格都不显示 NaN', async () => {
const freeEntry: AdminGameDistributionReviewEntry = {
...entry,
versionId: 'version-2',
gameId: 'game_2',
versionNumber: 1,
priceMudPoints: 0,
};
// 旧响应可能完全没有该字段;列表必须回落到“免费”而不是 NaN。
const legacyEntry = {
...entry,
versionId: 'version-3',
gameId: 'game_3',
versionNumber: 3,
priceMudPoints: undefined,
} as unknown as AdminGameDistributionReviewEntry;
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
entries: [entry, freeEntry, legacyEntry],
nextCursor: null,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText(gameTitle);
const table = screen.getByText(gameTitle).closest('table');
expect(table).not.toBeNull();
// 按列名定位:绑「第 4 个格子」的话,任何插列或换序都会读到别的字段,断言随之失效。
const priceColumnIndex = within(table as HTMLElement)
.getAllByRole('columnheader')
.findIndex((header) => header.textContent?.trim() === '价格');
expect(priceColumnIndex).toBeGreaterThanOrEqual(0);
const cellAt = (text: string) => {
const row = screen.getByText(text).closest('tr');
expect(row).not.toBeNull();
return within(row as HTMLElement).getAllByRole('cell')[priceColumnIndex]
?.textContent;
};
// 三行三态都按精确文案钉死:付费、免费与旧响应缺字段都不能渲染成 NaN。
expect(cellAt(gameTitle)).toBe('30 泥点');
expect(cellAt('game_2')).toBe('免费');
expect(cellAt('game_3')).toBe('免费');
});
test('取消理由输入时不做审核操作', async () => {
render(
<AdminGameDistributionReviewPage
@@ -300,6 +354,9 @@ test('详情展示发布者和冻结资料,并试玩当前待审版本', async
fireEvent.click(screen.getByRole('button', { name: '详情' }));
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
expect(screen.getByText('测试详细介绍')).toBeTruthy();
// 详情资料区展示买断制价格(0 或缺字段按“免费”)。
expect(screen.getByText('价格')).toBeTruthy();
expect(screen.getByText('30 泥点')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
const frame = await screen.findByTitle('测试游戏 待审版本试玩');
expect(frame.getAttribute('src')).toBe(
@@ -372,7 +429,6 @@ test('试玩加载超过上限后给出超时说明与重建会话入口', async
expiresAt: '2026-10-02T12:20:00Z',
versionId: 'version-1',
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
@@ -415,3 +471,60 @@ test('试玩加载超过上限后给出超时说明与重建会话入口', async
vi.useRealTimers();
}
});
test('详情价格只渲染服务端下发的 priceMudPoints,不按冻结资料二次取价', async () => {
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({
// 服务端 `version_detail_payload` 已把「待审版本冻结价优先、缺字段按 0」归一写进
// `game.priceMudPoints`,审核页只渲染这个字段。
game: { ...detailResponse.game, priceMudPoints: 30 },
version: {
...detailResponse.version,
// 对抗用例:冻结资料里的价格与服务端归一结果故意不同(999 ≠ 30)。客户端若自行
// 复刻「冻结价优先」,就会显示 999 —— 这里必须仍然是服务端字段的 30。
frozenMetadata: { title: '测试游戏', priceMudPoints: 999 },
},
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText(gameTitle);
fireEvent.click(screen.getByRole('button', { name: '详情' }));
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
expect(screen.getByText('价格')).toBeTruthy();
expect(screen.getByText('30 泥点')).toBeTruthy();
expect(screen.queryByText('999 泥点')).toBeNull();
expect(screen.queryByText('免费')).toBeNull();
});
test('旧版本冻结资料没有价格时按免费回落', async () => {
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue({
game: {
...detailResponse.game,
priceMudPoints: undefined,
} as unknown as AdminGameDistributionGameDetail,
version: {
...detailResponse.version,
frozenMetadata: { title: '测试游戏' },
},
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText(gameTitle);
fireEvent.click(screen.getByRole('button', { name: '详情' }));
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
expect(screen.getByText('免费')).toBeTruthy();
expect(screen.queryByText(/NaN/u)).toBeNull();
});
@@ -100,6 +100,8 @@ function applyFrozenGameMetadata(
typeof metadata.orientation === 'string'
? metadata.orientation
: game.orientation,
// 价格不在这里二次叠加:服务端 `version_detail_payload` 已按「版本冻结价优先、
// 缺字段按 0」把结果归一写进 `game.priceMudPoints`,客户端直接沿用该字段。
};
}
@@ -113,6 +115,11 @@ function formatBytes(value: number) {
return `${value} B`;
}
/** 买断制价格口径:0 或缺失(旧响应 / undefined)都按“免费”展示,避免出现 NaN。 */
function formatPriceMudPoints(value: number | null | undefined) {
return typeof value === 'number' && value > 0 ? `${value} 泥点` : '免费';
}
function createReviewIdempotencyKey(versionId: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
@@ -439,6 +446,7 @@ export function AdminGameDistributionReviewPage({
{ key: 'gameId', label: '游戏' },
{ key: 'versionNumber', label: '版本' },
{ key: 'packageBytes', label: '发行包' },
{ key: 'priceMudPoints', label: '价格' },
{ key: 'createdAt', label: '提交时间' },
{ key: 'actions', label: '审核' },
]}
@@ -484,6 +492,7 @@ export function AdminGameDistributionReviewPage({
<code>{entry.packageSha256.slice(0, 12)}</code>
</div>
</td>
<td>{formatPriceMudPoints(entry.priceMudPoints)}</td>
<td>{formatAdminDateTime(entry.createdAt)}</td>
<td className="admin-game-review-cell--actions">
<AdminActionRow className="admin-review-actions">
@@ -649,6 +658,10 @@ function AdminGameDistributionReviewDetailView({
versionLabel={`v${detail.version.versionNumber}`}
infoCards={[
{ label: '状态', value: detail.version.status },
{
label: '价格',
value: formatPriceMudPoints(detail.game.priceMudPoints),
},
{
label: '提交时间',
value: formatAdminDateTime(detail.version.createdAt),
@@ -27,14 +27,22 @@ table.admin-game-review-table td:nth-child(3) {
width: 124px;
}
/* 价格列:只展示「免费」或「N 泥点」,上限 1000000 泥点最长;给足宽度并允许换行,
固定列宽 + overflow:hidden 下保持 nowrap 会把最大价硬裁掉。 */
table.admin-game-review-table th:nth-child(4),
table.admin-game-review-table td:nth-child(4) {
width: 172px;
white-space: nowrap;
width: 120px;
white-space: normal;
}
table.admin-game-review-table th:nth-child(5),
table.admin-game-review-table td:nth-child(5) {
width: 172px;
white-space: nowrap;
}
table.admin-game-review-table th:nth-child(6),
table.admin-game-review-table td:nth-child(6) {
width: 240px;
}
@@ -100,7 +108,9 @@ table.admin-game-review-table td .admin-muted-text {
table.admin-game-review-table th:nth-child(4),
table.admin-game-review-table td:nth-child(4),
table.admin-game-review-table th:nth-child(5),
table.admin-game-review-table td:nth-child(5) {
table.admin-game-review-table td:nth-child(5),
table.admin-game-review-table th:nth-child(6),
table.admin-game-review-table td:nth-child(6) {
width: auto;
}
}
@@ -48,6 +48,9 @@ const PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress";
/// JS `Number.MAX_SAFE_INTEGER`:版本号经 JSON number 传递,超过它就会丢精度。
const MAX_SAFE_VERSION_NUMBER: u64 = 9_007_199_254_740_991;
/// 买断价上限(整数泥点),与服务端 `MAX_GAME_PRICE_MUD_POINTS` 及前端共享常量一致。
const MAX_GAME_PRICE_MUD_POINTS: u64 = 1_000_000;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct GameDistributionPublishResult {
@@ -101,6 +104,12 @@ pub(crate) struct GameDistributionPublicationDraft {
pub(crate) input_modes: Vec<GameDistributionInputMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) orientation: Option<GameDistributionOrientation>,
/// 更新面板预填的买断价(整数泥点,`0` 表示免费):取该作品**最近版本的冻结价**
/// (该版本可能仍处于 `pending_review` / `rejected`,因此预填值未必已生效);该版本
/// 冻结价缺失或为 `0` 时回落游戏行当前生效价,两条来源都取不到才按 `0`(免费),
/// 避免把已上线的付费作品预填成免费。旧原生响应缺该字段时按免费兜底。
#[serde(default)]
pub(crate) price_mud_points: u64,
}
/// 发布面板打开时的发布状态回读。
@@ -121,6 +130,13 @@ pub(crate) struct GameDistributionPublicationReadResult {
pub(crate) draft: Option<GameDistributionPublicationDraft>,
#[serde(default)]
pub(crate) visibility: Option<String>,
/// 平台侧这次发布实际会用的版本号(作者视图已有版本的最大 `versionNumber + 1`)。
///
/// 首次发布会新建作品,服务端从 `1` 开始派发,所以这里也是 `1`。拿不到平台数据的路径
/// (`unavailable`、以及「有本地绑定但没能刷新线上状态」)不下发:渲染层不得回落到本地工程
/// 迭代计数,只能据此禁用提交。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) next_version_number: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) message: Option<String>,
}
@@ -229,12 +245,22 @@ struct OwnerGameEntry {
publication_revision: u64,
#[serde(default)]
latest_version: Option<OwnerGameVersion>,
/// 该作者作品的版本列表(作者自有视图按最近更新倒序、单游戏最多 10 条)。
///
/// 平台只在客户端不传版本号时按 `max_existing + 1` 自动派发,所以「下一个版本号」必须取这里
/// 已下发版本的最大 `versionNumber`;`latestVersion` 是最近**更新**的那条(可能是被改过状态的
/// 历史版本),单靠它或本地工程迭代计数都会推出重复 / 回退的版本号。
#[serde(default)]
versions: Vec<OwnerGameVersion>,
#[serde(default)]
device_support: Option<GameDistributionDeviceSupport>,
#[serde(default)]
input_modes: Vec<String>,
#[serde(default)]
orientation: Option<String>,
/// 游戏行当前生效的买断价(作者自有视图的 `priceMudPoints`)。
#[serde(default)]
price_mud_points: u64,
}
#[derive(Clone, Debug, Default, Deserialize)]
@@ -245,6 +271,9 @@ struct OwnerGameVersion {
version_number: u64,
#[serde(default)]
status: String,
/// 该版本审核通过后冻结的买断价;历史版本缺省为 `0`。
#[serde(default)]
price_mud_points: u64,
}
#[derive(Clone, Debug, Deserialize)]
@@ -426,11 +455,19 @@ fn normalized_origin(value: &str) -> String {
value.trim().trim_end_matches('/').to_string()
}
fn metadata_digest(metadata: &GameDistributionCreateGameRequest) -> Result<String, String> {
/// 发布资料摘要:随版本冻结的资料与买断价一起参与。
///
/// 价格必须进摘要:同一份包、同一版本标签只改价格时,幂等账本必须换键,否则会重放上一次
/// 尝试的收据,把作者改后的价格静默丢掉。
fn metadata_digest(
metadata: &GameDistributionCreateGameRequest,
price_mud_points: u64,
) -> Result<String, String> {
let bytes =
serde_json::to_vec(metadata).map_err(|error| format!("计算发布资料摘要失败:{error}"))?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
hasher.update(price_mud_points.to_le_bytes());
Ok(format!("{:x}", hasher.finalize()))
}
@@ -508,6 +545,8 @@ fn first_publish_state(message: Option<String>) -> GameDistributionPublicationRe
refreshed: true,
draft: None,
visibility: None,
// 首次发布会新建作品,平台自己也从 `1` 派发这一版的版本号。
next_version_number: Some(1),
message,
}
}
@@ -519,15 +558,35 @@ fn unavailable_state(message: Option<String>) -> GameDistributionPublicationRead
refreshed: false,
draft: None,
visibility: None,
// 拿不到平台数据就不给版本号:渲染层据此禁用提交,不得回落到本地工程迭代计数。
next_version_number: None,
message,
}
}
/// 平台侧这次发布会用的版本号:作者视图里该作品已有版本的最大 `versionNumber + 1`。
///
/// 与平台自身的派发口径一致(`module-game-distribution::resolve_version_number` 的
/// `max_existing + 1`)。`latestVersion` 只是最近更新的一条,必须与版本列表一起取最大值。
/// 作者视图单游戏最多下发 10 条版本(`GAME_DISTRIBUTION_MAX_OWNER_VERSIONS_PER_GAME`):
/// 号最大的那条被挤出列表时会低估一个已经存在的版本号,这是当前作者视图的取数边界。
fn resolve_owner_next_version_number(entry: &OwnerGameEntry) -> u64 {
entry
.versions
.iter()
.chain(entry.latest_version.iter())
.map(|version| version.version_number)
.max()
.unwrap_or(0)
.saturating_add(1)
}
/// 回写发布绑定;本地遗留的 `projectVersion` 一并清空。
///
/// 绑定本身已按当前会话(账号 + origin)与 gameId 隔离。项目版本标签不再从绑定推导(它由
/// AGC 工程内部版本 `versions[]` 派生、只读),所以这里只落绑定,并把存量清单里可能残留的
/// 遗留标签置空——`skip_serializing_if` 会让该键随下一次落盘消失。
/// 绑定本身已按当前会话(账号 + origin)与 gameId 隔离。项目版本标签不从绑定推导,也不来自
/// AGC 工程内部版本:它取平台作者视图里该作品的最大版本号 + 1(发布时显式提交同一个值),
/// 所以这里只落绑定,并把存量清单里可能残留的遗留标签置空——`skip_serializing_if` 会让该键
/// 随下一次落盘消失。
fn write_publication_binding(
root: &Path,
binding: GameCreationAppPublicationBinding,
@@ -781,9 +840,21 @@ async fn build_publication_draft(
device_support,
input_modes: parse_input_modes(input_modes),
orientation: parse_orientation(orientation),
price_mud_points: resolve_publication_prefill_price(entry),
}
}
/// 更新面板预填的买断价:优先取最近版本的冻结价(与网页更新模式同源),该版本历史无价格时
/// 回退游戏行当前价。两条来源都拿不到才按 `0`(免费),避免把已上线的付费作品预填成免费。
fn resolve_publication_prefill_price(entry: &OwnerGameEntry) -> u64 {
entry
.latest_version
.as_ref()
.map(|version| version.price_mud_points)
.filter(|price| *price > 0)
.unwrap_or(entry.price_mud_points)
}
async fn bound_publication_result(
client: &reqwest::Client,
snapshot: &PlatformSessionSnapshot,
@@ -820,6 +891,7 @@ async fn bound_publication_result(
refreshed: true,
draft: Some(draft),
visibility: Some(entry.status.clone()).filter(|status| !status.is_empty()),
next_version_number: Some(resolve_owner_next_version_number(&entry)),
message: None,
})
}
@@ -872,12 +944,14 @@ pub(crate) async fn read_game_distribution_publication(
}
Err(GameDistributionReadError::Unavailable(message)) => {
// 结果未知:保留本地绑定,按更新模式返回但标注未刷新,由发布时的 CAS 兜底。
// 这一路没有平台版本数据,所以不给版本号:渲染层禁用提交,等下一次刷新成功。
Ok(GameDistributionPublicationReadResult {
state: "update".to_string(),
binding: Some(binding),
refreshed: false,
draft: None,
visibility: None,
next_version_number: None,
message: Some(format!("无法刷新线上状态:{message}")),
})
}
@@ -1237,6 +1311,8 @@ pub(crate) async fn publish_local_project_game(
version_number: Option<u64>,
expected_publication_revision: Option<u64>,
idempotency_key: Option<String>,
// 买断价(整数泥点,`0` 表示免费)。旧前端不传时为 `None`,按免费提交,保持向后兼容。
price_mud_points: Option<u64>,
) -> Result<GameDistributionPublishResult, String> {
let snapshot = require_platform_session()?;
let app_data_dir = app
@@ -1283,7 +1359,12 @@ pub(crate) async fn publish_local_project_game(
if target_game_id.is_some() && expected_publication_revision.is_none() {
return Err("缺少公开修订号,请重新打开发布面板后再试".to_string());
}
let digest = metadata_digest(&metadata)?;
// 买断价只允许 `0..=上限`;面板已按同一口径拦住非法输入,这里对命令边界再收一次口。
let price_mud_points = price_mud_points.unwrap_or(0);
if price_mud_points > MAX_GAME_PRICE_MUD_POINTS {
return Err(format!("买断价不能超过 {MAX_GAME_PRICE_MUD_POINTS} 泥点"));
}
let digest = metadata_digest(&metadata, price_mud_points)?;
// 根幂等键由账本按「账号 + origin + 本地项目 + 包摘要 + 目标游戏/版本 + 资料摘要」解析:
// 同一份包重发、响应丢失后重试、进程重启后的分片续传都落在同一次服务端尝试上;
// 包内容、目标版本标签或资料任一变化都换键,新标签的提交不会被幂等收据吞掉。
@@ -1360,6 +1441,8 @@ pub(crate) async fn publish_local_project_game(
package_file_count: staged.package_file_count,
package_entry_path: "index.html".to_string(),
version_number,
// 买断价随版本冻结;面板已本地校验,这里透传服务端口径的整数泥点。
price_mud_points,
game_metadata: metadata,
};
let version_value = request_json(
@@ -1482,7 +1565,8 @@ pub(crate) async fn publish_local_project_game(
}
});
// 发布成功:把平台作品绑定和最近版本状态写回本地清单。项目版本标签不在这里对齐——它由
// 工程内部版本派生,这次提交的平台版本号不回写本地(遗留 `projectVersion` 一并清空)。
// 平台作者视图的最大版本号 + 1 推出、在提交前已经决定了下一次要用的值(遗留 `projectVersion`
// 一并清空)。
// 写回失败不回滚已经成功的发布,但会把错误交给作者——否则下次打开项目会退回首次发布,
// 甚至可能重复建作品。
let binding = GameCreationAppPublicationBinding {
@@ -1495,7 +1579,7 @@ pub(crate) async fn publish_local_project_game(
status: Some(status.clone()),
};
let manifest = crate::project::mutate_manifest_at(root, |manifest| {
// 项目版本标签由工程内部版本派生,不由这次提交的平台版本号回写;遗留位一并清空。
// 项目版本标签由平台作者视图派生并在提交前传下去,不由这次提交的平台版本号回写;遗留位一并清空。
manifest.project_version = None;
manifest.publication = Some(binding.clone());
Ok(manifest.clone())
@@ -1631,11 +1715,13 @@ mod tests {
orientation:
shared_contracts::game_distribution::GameDistributionOrientation::Responsive,
};
let first = metadata_digest(&metadata).expect("digest");
let first = metadata_digest(&metadata, 0).expect("digest");
assert_eq!(first.len(), 64);
assert_eq!(metadata_digest(&metadata).expect("stable"), first);
assert_eq!(metadata_digest(&metadata, 0).expect("stable"), first);
// 只改价格的提交必须换幂等键:否则账本会重放上一次收据,静默丢掉改价。
assert_ne!(metadata_digest(&metadata, 120).expect("priced"), first);
metadata.title = "新标题".to_string();
assert_ne!(metadata_digest(&metadata).expect("changed"), first);
assert_ne!(metadata_digest(&metadata, 0).expect("changed"), first);
}
#[test]
@@ -1647,20 +1733,115 @@ mod tests {
"summary": "守住轨道城",
"status": "published",
"publicationRevision": 4,
"priceMudPoints": 240,
"latestVersion": {
"versionId": "gamever-2",
"versionNumber": 2,
"status": "pending_review"
}
"status": "pending_review",
"priceMudPoints": 300
},
"versions": [
{
"versionId": "gamever-2",
"versionNumber": 2,
"status": "pending_review",
"priceMudPoints": 300
},
{
"versionId": "gamever-5",
"versionNumber": 5,
"status": "published",
"priceMudPoints": 0
}
]
}))
.expect("owner entry");
assert_eq!(entry.id, "game-1");
assert_eq!(entry.local_project_id.as_deref(), Some("proj-1"));
assert_eq!(entry.publication_revision, 4);
assert_eq!(entry.price_mud_points, 240);
assert_eq!(entry.versions.len(), 2);
let latest = entry.latest_version.expect("latest version");
assert_eq!(latest.version_id, "gamever-2");
assert_eq!(latest.version_number, 2);
assert_eq!(latest.status, "pending_review");
assert_eq!(latest.price_mud_points, 300);
}
#[test]
fn next_version_number_follows_platform_maximum_not_latest_or_local_count() {
// 作者视图按最近更新倒序:`latestVersion` 是 v2(最近被改过状态),列表里号最大的是 v5。
// 平台派发口径是 max_existing + 1,所以下一个版本号必须是 6,不能是 2 + 1。
let entry = OwnerGameEntry {
latest_version: Some(OwnerGameVersion {
version_number: 2,
..Default::default()
}),
versions: vec![
OwnerGameVersion {
version_number: 2,
..Default::default()
},
OwnerGameVersion {
version_number: 5,
..Default::default()
},
],
..Default::default()
};
assert_eq!(resolve_owner_next_version_number(&entry), 6);
// 没有任何版本记录的作品:平台会从 1 开始派发。
assert_eq!(
resolve_owner_next_version_number(&OwnerGameEntry::default()),
1
);
// 列表被条数上限截断、只带回旧版本时也只能按已下发的最大值推出(取数边界见函数注释)。
let truncated = OwnerGameEntry {
latest_version: None,
versions: vec![OwnerGameVersion {
version_number: 1,
..Default::default()
}],
..Default::default()
};
assert_eq!(resolve_owner_next_version_number(&truncated), 2);
}
#[test]
fn publication_prefill_price_prefers_latest_frozen_price_and_falls_back() {
// 最新版本冻结价 > 0:与网页更新模式同源,直接用它。
let paid = OwnerGameEntry {
price_mud_points: 240,
latest_version: Some(OwnerGameVersion {
price_mud_points: 300,
..Default::default()
}),
..Default::default()
};
assert_eq!(resolve_publication_prefill_price(&paid), 300);
// 历史版本没有冻结价(0):回退游戏行当前价,避免把已上线的付费作品预填成免费。
let legacy = OwnerGameEntry {
price_mud_points: 240,
latest_version: Some(OwnerGameVersion::default()),
..Default::default()
};
assert_eq!(resolve_publication_prefill_price(&legacy), 240);
// 免费作品、以及完全缺该字段的旧响应,都按 0(免费)。
assert_eq!(
resolve_publication_prefill_price(&OwnerGameEntry::default()),
0
);
let legacy_entry = parse_owner_game_entry(json!({
"id": "game-2",
"title": "免费游戏",
"latestVersion": { "versionId": "v1", "versionNumber": 1, "status": "published" }
}))
.expect("legacy owner entry");
assert_eq!(resolve_publication_prefill_price(&legacy_entry), 0);
}
#[test]
@@ -36,8 +36,8 @@ struct GamePublishAttemptRecord {
/// 目标游戏身份:更新已有作品时非空;首次发布为空。
#[serde(default)]
target_game_id: Option<String>,
/// 这次发布提交的项目版本标签(由清单 `versions[]` 的条数派生,不由 `projectVersion`
/// 这种遗留兼容位提供):换了标签就是一次新尝试。
/// 这次发布提交的平台版本号(作者视图里该作品的最大版本号 + 1,首次发布为 `1`;
/// 不由清单 `versions[]` 的条数或遗留 `projectVersion` 位提供):换了版本号就是一次新尝试。
#[serde(default)]
target_version_number: Option<u64>,
/// 随版本冻结的资料摘要:同一份包换了资料修订也必须换根键,不能只按 ZIP 摘要吞掉新发布。
@@ -207,9 +207,9 @@ fn validate_manifest_cocos_project_root(value: Option<&str>) -> Result<(), Strin
/// 遗留字段 `projectVersion` 的取值范围:缺省合法(旧清单),写了就必须是正整数。
///
/// 它**不参与**发布版本标签的推导(标签由 `versions[]` 的条数派生),只是为了让存量清单继续
/// 可读。`0` 不是"未设置"的别名——未设置就是字段缺失;一旦落盘 0 会被当成非法值,所以在读
/// 写两侧都失败关闭,避免坏值被静默传播。
/// 它**不参与**发布版本号的推导(版本号取平台作者视图里该作品的最大版本号 + 1),只是为了让
/// 存量清单继续可读。`0` 不是"未设置"的别名——未设置就是字段缺失;一旦落盘 0 会被当成非法值,
/// 所以在读写两侧都失败关闭,避免坏值被静默传播。
fn validate_manifest_project_version(value: Option<u64>) -> Result<(), String> {
if value == Some(0) {
return Err("manifest projectVersion 必须是大于 0 的整数".to_string());
@@ -1,10 +1,12 @@
import { Eye, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
type GameCreationAppManifest,
resolveGameCreationAppProjectVersion,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
PlatformGamePricingField,
type PlatformGamePricingMode,
resolvePlatformGamePriceMudPoints,
} from '@genarrative/shared/components';
import { Eye, Trash2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { type GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { GAME_DISTRIBUTION_CATEGORIES } from '../../../../../packages/shared/src/contracts/gameDistribution';
import { IMAGE_MODEL_GPT_IMAGE_2 } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel';
import { resolveTauriInvoke } from '../../app/tauri';
@@ -237,6 +239,10 @@ export function GameDistributionPublishPanel({
const [summary, setSummary] = useState('');
const [category, setCategory] =
useState<GameDistributionPublishMetadata['category']>('其他');
/** 付费方式与买断价输入;校验口径与网页、服务端一致。 */
const [priceMode, setPriceMode] = useState<PlatformGamePricingMode>('free');
const [priceInput, setPriceInput] = useState('');
const [priceError, setPriceError] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<GameDistributionPublishResult | null>(
@@ -270,14 +276,15 @@ export function GameDistributionPublishPanel({
/** 线上最近提交的版本号;首次发布为 0(没有线上版本)。 */
const [latestVersionNumber, setLatestVersionNumber] = useState(0);
/**
* 面板展示并提交的「项目版本」:AGC 工程内部版本序数,唯一来源是清单的 `versions[]`。
* 平台侧这次发布会用的版本号:作者视图里该作品已有版本的最大 `versionNumber + 1`
* (首次发布为 `1`),由原生回读下发。
*
* 只读派生值,不是可编辑 state:用户不能改,平台版本号也改不了它。每次智能体修订追加一条
* 内部版本,这个值随之推进。
* `null` = 还没拿到平台数据(恢复失败、或更新模式没能刷新线上状态):这时不允许提交,
* 也不回落到 AGC 工程内部版本 `versions[]` 的条数——那是本地迭代计数,会提交重复 /
* 回退的平台版本号。面板展示的值与提交的值都只取这一个来源。
*/
const projectVersion = useMemo(
() => resolveGameCreationAppProjectVersion(manifest),
[manifest],
const [nextVersionNumber, setNextVersionNumber] = useState<number | null>(
null,
);
const metadataSuggestionRequestRef = useRef(0);
const publicationRequestRef = useRef(0);
@@ -304,6 +311,9 @@ export function GameDistributionPublishPanel({
setTitle(manifest.name.trim());
setSummary(fallbackSummary);
setCategory('其他');
setPriceMode('free');
setPriceInput('');
setPriceError('');
setCover(null);
setScreenshots([]);
setBusy(false);
@@ -320,6 +330,7 @@ export function GameDistributionPublishPanel({
setPublicationRefreshed(false);
setPublicationMessage('');
setLatestVersionNumber(0);
setNextVersionNumber(null);
summaryTouchedRef.current = false;
categoryTouchedRef.current = false;
publicationDraftAppliedRef.current = false;
@@ -359,7 +370,8 @@ export function GameDistributionPublishPanel({
// 面板据此禁用提交而不是退化成首次发布。
const invoke = resolveTauriInvoke();
if (!invoke) {
// 非 Tauri 宿主:没有可恢复的原生状态,交给提交时的失败提示兜底。
// 非 Tauri 宿主:没有可恢复的原生状态,也就拿不到平台版本号——按首次发布展示表单,
// 提交按钮因为版本号未知保持禁用(发布本身也需要原生宿主)。
setPublicationState('first-publish');
return;
}
@@ -375,8 +387,15 @@ export function GameDistributionPublishPanel({
);
return;
}
// 版本号只认原生按平台作者视图推出来的值;缺字段(旧原生端 / 没刷新成功)就保持未知。
const platformNextVersion = recovery.nextVersionNumber;
setNextVersionNumber(
typeof platformNextVersion === 'number' && platformNextVersion > 0
? platformNextVersion
: null,
);
if (recovery.state === 'update') {
// 线上最近提交的版本号只用于展示平台侧事实;面板的项目版本由内部版本派生,与它无关。
// 线上最近提交的版本号只用于展示平台侧事实,与这次要用的版本号无关。
const latestVersionNumber = recovery.binding.latestVersionNumber ?? 0;
setPublicationState('update');
setPublicationBinding(recovery.binding);
@@ -422,6 +441,20 @@ export function GameDistributionPublishPanel({
if (!categoryTouchedRef.current && draft.category) {
setCategory(draft.category);
}
// 线上价格预填:付费作品进面板默认免费会在送审后把作品改成免费,这里按回读的
// 当前价预填,作者不改就不变。历史响应缺该字段时按免费兜底。
const existingPriceMudPoints = draft.priceMudPoints ?? 0;
if (
typeof existingPriceMudPoints === 'number' &&
existingPriceMudPoints > 0
) {
setPriceMode('paid');
setPriceInput(String(existingPriceMudPoints));
} else {
setPriceMode('free');
setPriceInput('');
}
setPriceError('');
if (draft.cover && (draft.cover.assetId || draft.cover.previewUrl)) {
setCover(buildRestoredCover(draft.cover));
}
@@ -656,6 +689,11 @@ export function GameDistributionPublishPanel({
setError('请先导出有效的试玩包');
return;
}
// 版本号必须来自平台作者视图;拿不到就不发请求,也不猜本地工程迭代计数。
if (nextVersionNumber === null) {
setError('无法确认项目版本,请重新打开发布面板后再试');
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setError('需要在 Tauri App 内发布');
@@ -686,6 +724,12 @@ export function GameDistributionPublishPanel({
setError('素材还在上传中,请稍候再发布');
return;
}
const price = resolvePlatformGamePriceMudPoints(priceMode, priceInput);
if (price.error) {
setPriceError(price.error);
return;
}
setPriceError('');
setBusy(true);
setError('');
setPublishPhase({
@@ -741,7 +785,9 @@ export function GameDistributionPublishPanel({
publicationState === 'update'
? (publicationBinding?.gameId ?? null)
: null,
versionNumber: projectVersion,
// 展示与提交同一个平台版本号(作者视图最大版本号 + 1,首次发布为 1)。
versionNumber: nextVersionNumber,
priceMudPoints: price.priceMudPoints,
expectedPublicationRevision:
publicationState === 'update'
? (publicationBinding?.revision ?? null)
@@ -891,24 +937,42 @@ export function GameDistributionPublishPanel({
))}
</select>
</label>
<label>
<div className="game-distribution-publish-panel__version-field">
<span className="game-distribution-publish-panel__version-label">
项目版本
<span className="game-distribution-publish-panel__version-tag">
{`v${projectVersion}`}
{nextVersionNumber === null ? '—' : `v${nextVersionNumber}`}
</span>
</span>
{/* 平台派发的版本号,作者不能填:只读 + 禁用观感,提交的也是同一个值。
保留 readOnly 而不是 disabled:值仍可聚焦 / 可复制,读屏也能念出来。 */}
<input
type="number"
min={1}
step={1}
className="game-distribution-publish-panel__version-value"
type="text"
inputMode="numeric"
aria-label="项目版本"
readOnly
aria-readonly="true"
value={projectVersion}
value={
nextVersionNumber === null ? '' : String(nextVersionNumber)
}
placeholder="—"
/>
</label>
</div>
<PlatformGamePricingField
mode={priceMode}
priceInput={priceInput}
disabled={busy}
error={priceError}
onModeChange={(nextMode) => {
setPriceMode(nextMode);
setPriceError('');
}}
onPriceInputChange={(nextValue) => {
setPriceInput(nextValue);
setPriceError('');
}}
/>
</div>
<div
className="game-distribution-publish-panel__media"
@@ -1125,6 +1189,7 @@ export function GameDistributionPublishPanel({
Boolean(uploadingLabel) ||
publicationState === 'checking' ||
publicationState === 'unavailable' ||
nextVersionNumber === null ||
!title.trim() ||
!summary.trim() ||
!packageResult
@@ -1,7 +1,8 @@
import { PLATFORM_GAME_MAX_PRICE_MUD_POINTS } from '@genarrative/shared/components';
import {
type GameCreationAppManifest,
type GameCreationAppPublicationBinding,
resolveGameCreationAppProjectVersion,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import type {
GameDistributionCategory,
@@ -74,6 +75,13 @@ export type GameDistributionPublicationDraft = {
deviceSupport?: GameDistributionDeviceSupport | null;
inputModes?: GameDistributionInputMode[];
orientation?: GameDistributionOrientation | null;
/**
* 线上该作品当前的买断价(整数泥点,`0` 表示免费)。
*
* 更新面板据此预填价格与模式:作者不改就不会把已上线的付费作品静默改成免费。
* 旧原生端不返回该字段,读取方按 `0`(免费)兜底。
*/
priceMudPoints?: number | null;
};
/**
@@ -81,6 +89,9 @@ export type GameDistributionPublicationDraft = {
*
* `state` 三态必须分开处理:`unavailable` 表示恢复被网络/401/5xx 挡住,绝不能当成首次发布;
* `first-publish` 只在服务端给出确定答案(无绑定且作者名下没有匹配作品)时出现。
*
* `nextVersionNumber` 是平台侧这次发布会用的版本号(作者视图已有版本的最大 `versionNumber + 1`;
* 首次发布为 `1`)。取不到平台数据时缺省,调用方不得回落到本地工程迭代计数,只能禁用提交。
*/
export type GameDistributionPublicationReadResult =
| {
@@ -89,6 +100,7 @@ export type GameDistributionPublicationReadResult =
refreshed: boolean;
draft: null;
visibility: null;
nextVersionNumber?: number | null;
message?: string | null;
}
| {
@@ -97,6 +109,7 @@ export type GameDistributionPublicationReadResult =
refreshed: boolean;
draft: GameDistributionPublicationDraft | null;
visibility?: string | null;
nextVersionNumber?: number | null;
message?: string | null;
}
| {
@@ -105,6 +118,7 @@ export type GameDistributionPublicationReadResult =
refreshed: false;
draft: null;
visibility: null;
nextVersionNumber?: number | null;
message?: string | null;
};
@@ -299,15 +313,23 @@ export async function publishLocalProjectGame(args: {
*/
gameId?: string | null;
/**
* 这次提交使用的项目版本标签。
* 这次提交使用的平台版本号。
*
* 缺省时按 AGC 工程内部版本 `versions[]` 派生(见 `resolveGameCreationAppProjectVersion`):
* 标签等于当前存活的内部版本条数,发布面板展示的只读值就是它,不存在第二份版本来源,也不
* 读取平台绑定里的版本号。同一个版本标签可以反复提交,每次提交生成新的 `versionId`。
* **必填**:只接受调用方从平台作者视图推出的值(该作品已有版本的最大 `versionNumber + 1`,
* 首次发布为 `1`),也就是发布面板展示给作者的同一个数。这里刻意不回落到 AGC 工程内部版本
* `versions[]` 的条数——那是本地迭代计数,与平台版本序列无关,会提交重复 / 回退的版本号。
* 同一个版本号可以反复提交(每次提交生成新的 `versionId`)。
*/
versionNumber?: number | null;
versionNumber: number;
/** 更新送审所需的公开修订号 CAS;来自发布绑定。 */
expectedPublicationRevision?: number | null;
/**
* 本版本的买断价(整数泥点,`0` 表示免费)。
*
* 缺省或为空按免费提交,兼容未接入定价的调用方;服务端仍会独立校验
* `0..=1_000_000`。发布面板在提交前已按同一口径拦住非法输入。
*/
priceMudPoints?: number | null;
/**
* 发布根幂等键。渲染层默认**不**生成也不持久化它:原生发布链路按
* 「账号身份 + 本地项目 + 发行包摘要 + 目标游戏/版本 + 资料摘要」在应用数据里维护发布
@@ -327,13 +349,23 @@ export async function publishLocalProjectGame(args: {
throw new Error('发布需要本地项目标识,请重新打开项目后再试');
}
const gameId = args.gameId?.trim() ?? '';
// 版本标签只有一个来源:调用方显式传入的值优先(发布面板展示的派生值),否则回落到按 AGC
// 工程内部版本派生的同一个值。服务端的自动递增路径只服务不传版本号的老客户端。
const versionNumber =
args.versionNumber ?? resolveGameCreationAppProjectVersion(args.manifest);
// 版本号只有一个来源:调用方按平台作者视图推出的值(发布面板展示的同一个只读值)。
// 不回落本地工程迭代计数:那是「本地有几条迭代」而不是「平台版本序列的下一个号」,
// 提交重复 / 回退的版本号时平台不报错,版本序列会乱。
const versionNumber = args.versionNumber;
if (!Number.isSafeInteger(versionNumber) || versionNumber <= 0) {
throw new Error('项目版本必须是大于 0 的安全整数');
}
const priceMudPoints = args.priceMudPoints ?? 0;
if (
!Number.isSafeInteger(priceMudPoints) ||
priceMudPoints < 0 ||
priceMudPoints > PLATFORM_GAME_MAX_PRICE_MUD_POINTS
) {
throw new Error(
`买断价必须是 0 到 ${PLATFORM_GAME_MAX_PRICE_MUD_POINTS} 之间的整数泥点`,
);
}
if (
gameId &&
(args.expectedPublicationRevision === null ||
@@ -353,6 +385,7 @@ export async function publishLocalProjectGame(args: {
metadata: gameMetadata,
...(gameId ? { gameId } : {}),
versionNumber,
priceMudPoints,
...(gameId
? { expectedPublicationRevision: args.expectedPublicationRevision }
: {}),
+26 -1
View File
@@ -3761,7 +3761,32 @@ textarea {
line-height: 1.5;
}
/* 项目版本标签:值就是 AGC 工程内部版本序数(资源总览「项目版本」栏目里最新那张卡的「版本 N」)。 */
/* 项目版本字段:值是平台按作者视图派发的下一个版本号,作者不能填。
外壳与 `.game-distribution-publish-panel__fields label` 同形(grid + 6px 间距),
值用禁用观感(灰底 + 弱化文字 + not-allowed),与同组其它可编辑输入一眼分得开。 */
.game-distribution-publish-panel__version-field {
display: grid;
gap: 6px;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 700;
}
.game-distribution-publish-panel__fields
.game-distribution-publish-panel__version-value {
background: rgb(244 238 232 / 88%);
color: var(--platform-text-muted);
cursor: not-allowed;
}
.game-distribution-publish-panel__fields
.game-distribution-publish-panel__version-value:focus-visible {
border-color: var(--platform-subpanel-border);
outline: 2px solid rgb(168 102 61 / 20%);
outline-offset: 1px;
}
/* 项目版本标签:值是**平台**这次发布会用的版本号(作者视图最大版本号 + 1)。 */
.game-distribution-publish-panel__version-label {
display: flex;
align-items: center;
@@ -34,10 +34,10 @@ export type ProjectVersionResourceSummary = GameIterationVersion & {
childVersionIds: string[];
/**
* 只有**最新内部版本**(`manifest.versions` 最后一条)带它,值是内部版本序数
* (= `versions.length`,也就是发布时会提交的那个标签,与发布面板的
* `resolveGameCreationAppProjectVersion` 同源)。
* (= `versions.length`)。
*
* 卡面据此只渲染**中性**的「最新」角标:发布固定取最新内部版本,卡面不把它写成
* 它是 AGC 工程内部的迭代序数,**不是**发布时提交的平台版本号(后者取平台作者视图里
* 该作品的最大版本号 + 1)。卡面据此只渲染**中性**的「最新」角标,不把它写成
* 「发布标签」,免得诱导用户以为要挑一张卡才能发。
*/
publicationTag?: number;
@@ -69,6 +69,8 @@ describe('Rust 发布 facade', () => {
coverAssetId: 'asset_cover',
screenshots: ['asset_shot_1', 'asset_shot_2'],
},
// 平台版本号必填:调用方按作者视图推出的值,这里直接透传。
versionNumber: 4,
idempotencyKey: 'agc-publish-test',
});
@@ -79,6 +81,8 @@ describe('Rust 发布 facade', () => {
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
idempotencyKey: 'agc-publish-test',
// 未传价格时按免费提交,保持旧调用方向后兼容。
priceMudPoints: 0,
metadata: {
localProjectId: 'local-proj-1',
coverAssetId: 'asset_cover',
@@ -102,6 +106,7 @@ describe('Rust 发布 facade', () => {
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
versionNumber: 4,
});
expect(calls).toHaveLength(1);
@@ -124,6 +129,7 @@ describe('Rust 发布 facade', () => {
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: { ...MANIFEST, projectId: ' ' } as GameCreationAppManifest,
metadata: { coverAssetId: 'asset_cover' },
versionNumber: 4,
}),
).rejects.toThrow('发布需要本地项目标识');
expect(invoke).not.toHaveBeenCalled();
@@ -138,6 +144,7 @@ describe('Rust 发布 facade', () => {
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: ' ' },
versionNumber: 4,
}),
).rejects.toThrow('请先选择游戏封面');
expect(invoke).not.toHaveBeenCalled();
@@ -158,11 +165,51 @@ describe('Rust 发布 facade', () => {
(_, index) => `asset_${index}`,
),
},
versionNumber: 4,
}),
).rejects.toThrow('游戏截图最多 6 张');
expect(invoke).not.toHaveBeenCalled();
});
test('买断价原样透传给 native 发布命令', async () => {
const { invoke, calls } = installNativeInvoke((command) => {
if (command === 'publish_local_project_game') return PUBLISH_RESULT;
throw new Error(`未预期的命令:${command}`);
});
await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
versionNumber: 4,
priceMudPoints: 240,
});
expect(calls).toHaveLength(1);
expect(calls[0]?.args).toMatchObject({ priceMudPoints: 240 });
});
test('买断价越界或非整数时在 native command 前失败关闭', async () => {
const invoke = vi.fn() as unknown as TauriInvoke;
const base = {
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
versionNumber: 4,
};
await expect(
publishLocalProjectGame({ ...base, priceMudPoints: 1_000_001 }),
).rejects.toThrow('买断价必须是 0 到 1000000 之间的整数泥点');
await expect(
publishLocalProjectGame({ ...base, priceMudPoints: 1.5 }),
).rejects.toThrow('买断价必须是 0 到 1000000 之间的整数泥点');
expect(invoke).not.toHaveBeenCalled();
});
test('资料建议和价格均通过 native command', async () => {
const { invoke, calls } = installNativeInvoke((command) => {
if (command === 'suggest_game_distribution_publish_metadata') {
@@ -312,21 +359,23 @@ describe('Rust 发布 facade', () => {
});
});
test('未显式给出项目版本时按工程内部版本条数派生', async () => {
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
test('项目版本只认调用方传入的平台版本号:缺省失败关闭,本地迭代计数不参与', async () => {
// 缺省:不回落本地工程迭代计数(那是「本地有几条迭代」,不是平台版本序列的下一个号),
// 直接失败关闭,避免提交重复 / 回退的版本号。
const missingInvoke = vi.fn() as unknown as TauriInvoke;
await expect(
publishLocalProjectGame({
invoke: missingInvoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
} as unknown as Parameters<typeof publishLocalProjectGame>[0]),
).rejects.toThrow('项目版本必须是大于 0 的安全整数');
expect(missingInvoke).not.toHaveBeenCalled();
// 没有任何内部版本记录:首版回落 1,不把「没写」变成服务端自动递增。
await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
});
expect(calls[0]?.args).toMatchObject({ versionNumber: 1 });
// 4 条内部版本 → 标签就是最新那张卡的序数 4;平台绑定里的版本号不参与派生。
const withVersions = {
// 本地有 4 条内部版本、还有遗留 projectVersion 与绑定里的版本号:都不能改写传下去的值。
const withLocalHistory = {
...MANIFEST,
versions: [
{ versionId: 'initial-13', parentVersionId: null, projectRevision: 13 },
@@ -349,20 +398,17 @@ describe('Rust 发布 facade', () => {
projectVersion: 6,
publication: { latestVersionNumber: 6 },
} as unknown as GameCreationAppManifest;
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: withVersions,
manifest: withLocalHistory,
metadata: { coverAssetId: 'asset_cover' },
gameId: 'game_9',
expectedPublicationRevision: 7,
});
expect(calls[1]?.args).toMatchObject({
gameId: 'game_9',
versionNumber: 4,
expectedPublicationRevision: 7,
versionNumber: 7,
});
expect(calls[0]?.args).toMatchObject({ versionNumber: 7 });
expect(calls[0]?.args).not.toMatchObject({ versionNumber: 4 });
});
test('更新发布缺少公开修订号时在 native command 前失败关闭', async () => {
@@ -639,6 +639,8 @@ liveTest(
packageRelativePath: 'dist/game.zip',
manifest,
metadata: { ...metadata, inputModes: [...metadata.inputModes] },
// 平台侧接下来会用的版本号由作者视图推出:这个项目刚建,平台从 1 开始派发。
versionNumber: 1,
});
expect(first.status).toBe('pending_review');
expect(first.versionNumber).toBe(1);
@@ -676,6 +678,8 @@ liveTest(
packageRelativePath: 'dist/game.zip',
manifest,
metadata: { ...metadata, inputModes: [...metadata.inputModes] },
// 同一作品第二次发布:作者视图已有 v1 → 这次用 2。
versionNumber: 2,
});
expect(second.gameId).toBe(first.gameId);
expect(second.versionNumber).toBe(first.versionNumber + 1);
@@ -129,12 +129,14 @@ beforeEach(() => {
() => new Promise<never>(() => undefined),
);
// 默认按「首次发布」恢复,保持既有用例的提交路径不变;更新模式用例单独覆盖。
// `nextVersionNumber` 是原生按平台作者视图推出来的值:首次发布没有线上作品,平台从 1 派发。
vi.mocked(readGameDistributionPublication).mockResolvedValue({
state: 'first-publish',
binding: null,
refreshed: true,
draft: null,
visibility: null,
nextVersionNumber: 1,
});
});
@@ -330,6 +332,8 @@ describe('GameDistributionPublishPanel', () => {
// 根幂等键由原生发布链路按「账号 + 本地项目 + 包摘要」的账本解析并持久化,
// 渲染层不再自己铸键,因此这里不带 idempotencyKey。
expect(args?.idempotencyKey).toBeUndefined();
// 默认免费:不改付费方式就按 0 泥点提交。
expect(args?.priceMudPoints).toBe(0);
expect(invoke).toBeDefined();
expect(await screen.findByText('已提交审核')).not.toBeNull();
@@ -337,6 +341,47 @@ describe('GameDistributionPublishPanel', () => {
expect(onPublished).toHaveBeenCalledTimes(1);
});
test('买断制先本地校验价格,合法时随版本提交', async () => {
installTauriInvoke(async () => undefined);
vi.mocked(publishLocalProjectGame).mockResolvedValue({
gameId: 'game_1',
versionId: 'gamever_1',
versionNumber: 1,
status: 'pending_review',
packageSha256: 'a'.repeat(64),
packageSizeBytes: 3,
fileCount: 2,
});
renderPanel();
await selectCover();
const submit = await screen.findByRole('button', { name: '发布游戏' });
fireEvent.click(screen.getByLabelText('买断制'));
// 空价格先被本地拦住,不发任何发布请求。
fireEvent.click(submit);
expect(await screen.findByText('买断价必须是整数泥点')).not.toBeNull();
expect(publishLocalProjectGame).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText(/买断价/u), {
target: { value: '1000001' },
});
fireEvent.click(submit);
expect(
await screen.findByText('买断价必须是 1 到 1000000 之间的整数泥点'),
).not.toBeNull();
expect(publishLocalProjectGame).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText(/买断价/u), {
target: { value: '120' },
});
fireEvent.click(submit);
await waitFor(() =>
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
);
const args = vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0];
expect(args?.priceMudPoints).toBe(120);
});
test('连续点击只提交一次,上传中禁用动作按钮', async () => {
installTauriInvoke(async () => undefined);
let resolvePublish: (value: unknown) => void = () => undefined;
@@ -670,8 +715,10 @@ describe('GameDistributionPublishPanel', () => {
test('不在 Tauri 宿主或缺少试玩包时失败关闭且不调用发布接口', async () => {
renderPanel();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
expect(await screen.findByText('需要在 Tauri App 内发布')).not.toBeNull();
// 没有原生宿主就没有平台版本号:不猜本地迭代计数,提交按钮保持禁用。
expect(
await screen.findByRole('button', { name: '发布游戏' }),
).toHaveProperty('disabled', true);
expect(publishLocalProjectGame).not.toHaveBeenCalled();
cleanup();
@@ -683,7 +730,7 @@ describe('GameDistributionPublishPanel', () => {
expect(screen.queryByLabelText('发行包摘要')).toBeNull();
});
test('已有绑定时进入更新模式,回填线上资料并按派生项目版本提交原 gameId', async () => {
test('已有绑定时进入更新模式,回填线上资料并提交平台下一个版本号', async () => {
installTauriInvoke(async () => undefined);
vi.mocked(readGameDistributionPublication).mockResolvedValue({
state: 'update',
@@ -697,6 +744,7 @@ describe('GameDistributionPublishPanel', () => {
status: 'pending_review',
},
refreshed: true,
nextVersionNumber: 3,
draft: {
title: '星轨防线(线上)',
summary: '线上简介',
@@ -730,8 +778,7 @@ describe('GameDistributionPublishPanel', () => {
});
const { onPublished } = renderPanel();
// 回填线上冻结资料;项目版本按工程内部版本派生(本 fixture 没有 versions → 首版 1),
// 平台绑定的线上版本号只用于展示,不参与派生。
// 回填线上冻结资料;项目版本取平台作者视图的下一个版本号(已有 v2 → 这次用 3)。
await screen.findByRole('button', { name: '更新游戏' });
expect(screen.getByLabelText('游戏名称')).toHaveProperty(
'value',
@@ -741,10 +788,11 @@ describe('GameDistributionPublishPanel', () => {
'value',
'线上简介',
);
const versionInput = screen.getByLabelText('项目版本');
expect(versionInput).toHaveProperty('value', '1');
expect(versionInput).toHaveProperty('readOnly', true);
expect(screen.getByText('v1')).not.toBeNull();
const versionInput = screen.getByLabelText('项目版本') as HTMLInputElement;
expect(versionInput.value).toBe('3');
expect(versionInput.readOnly).toBe(true);
expect(versionInput.getAttribute('aria-readonly')).toBe('true');
expect(screen.getByText('v3')).not.toBeNull();
expect(screen.getByText(/线上最近提交 v2 · 审核中/u)).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '更新游戏' }));
@@ -753,9 +801,11 @@ describe('GameDistributionPublishPanel', () => {
);
const args = vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0];
expect(args?.gameId).toBe('game_9');
// 提交的就是派生出来的内部版本序数,与线上最近提交的 v2 无关。
expect(args?.versionNumber).toBe(1);
// 展示给作者的值与提交的值是同一个:平台已有 v2 → 提交 3,与本地迭代计数无关。
expect(args?.versionNumber).toBe(3);
expect(args?.expectedPublicationRevision).toBe(7);
// 该草稿是历史响应,没有回读价格:按免费预填,不填就提交 0。
expect(args?.priceMudPoints).toBe(0);
// 冻结素材 ID 直接复用,不要求作者重新上传封面/截图。
expect(args?.metadata?.coverAssetId).toBe('asset_cover_old');
expect(args?.metadata?.screenshots).toEqual(['asset_shot_old']);
@@ -771,7 +821,7 @@ describe('GameDistributionPublishPanel', () => {
expect(onPublished).toHaveBeenCalledTimes(1);
});
test('项目版本由工程内部版本条数派生,线上版本号与遗留字段都不参与', async () => {
test('更新模式预填线上买断价,作者不改就按原价提交', async () => {
installTauriInvoke(async () => undefined);
vi.mocked(readGameDistributionPublication).mockResolvedValue({
state: 'update',
@@ -780,17 +830,74 @@ describe('GameDistributionPublishPanel', () => {
apiBaseUrl: 'https://dev.test',
gameId: 'game_9',
revision: 7,
latestVersionId: 'gamever_5',
latestVersionNumber: 5,
latestVersionId: 'gamever_2',
latestVersionNumber: 2,
status: 'published',
},
refreshed: true,
nextVersionNumber: 3,
draft: {
title: '星轨防线(线上)',
summary: '线上简介',
description: '线上详介',
category: '策略',
tags: ['塔防'],
cover: {
assetId: 'asset_cover_old',
previewUrl: 'https://assets.test/cover.png',
},
screenshots: [],
priceMudPoints: 240,
},
visibility: 'published',
});
vi.mocked(publishLocalProjectGame).mockResolvedValue({
gameId: 'game_9',
versionId: 'gamever_3',
versionNumber: 1,
status: 'pending_review',
packageSha256: 'a'.repeat(64),
packageSizeBytes: 3,
fileCount: 2,
});
renderPanel();
const submit = await screen.findByRole('button', { name: '更新游戏' });
// 预填线上价与模式:不改就按原价提交,避免审核通过后把付费作品静默改成免费。
expect(screen.getByLabelText('买断制')).toHaveProperty('checked', true);
expect(screen.getByLabelText(/买断价/u)).toHaveProperty('value', '240');
fireEvent.click(submit);
await waitFor(() =>
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
);
expect(
vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0].priceMudPoints,
).toBe(240);
});
test('项目版本取平台作者视图的下一个版本号:本地迭代计数与遗留字段都不参与', async () => {
installTauriInvoke(async () => undefined);
vi.mocked(readGameDistributionPublication).mockResolvedValue({
state: 'update',
binding: {
accountId: 'user-1',
apiBaseUrl: 'https://dev.test',
gameId: 'game_9',
revision: 7,
latestVersionId: 'gamever_3',
latestVersionNumber: 3,
status: 'published',
},
refreshed: true,
draft: null,
visibility: 'published',
// 原生按作者视图算出「已有版本号 1..3 → 这次用 4」。
nextVersionNumber: 4,
});
vi.mocked(publishLocalProjectGame).mockResolvedValue({
gameId: 'game_9',
versionId: 'gamever_6',
versionId: 'gamever_4',
versionNumber: 4,
status: 'pending_review',
packageSha256: 'a'.repeat(64),
@@ -800,48 +907,96 @@ describe('GameDistributionPublishPanel', () => {
renderPanel({
manifest: {
...MANIFEST,
// 4 条内部版本 → 标签 4;遗留 projectVersion 6 与线上 v5 都不得影响它。
// 本地只有 1 条内部版本、遗留 projectVersion 6:按旧口径会提交 1(或 2),
// 这次必须采用平台推出的 4。
versions: [
{
versionId: 'initial-13',
parentVersionId: null,
projectRevision: 13,
},
{
versionId: 'agent-22',
parentVersionId: 'initial-13',
projectRevision: 22,
},
{
versionId: 'agent-42',
parentVersionId: 'agent-22',
projectRevision: 42,
},
{
versionId: 'agent-46',
parentVersionId: 'agent-42',
projectRevision: 46,
},
],
projectVersion: 6,
} as unknown as GameCreationAppManifest,
});
await screen.findByRole('button', { name: '更新游戏' });
const input = screen.getByLabelText('项目版本');
expect(input).toHaveProperty('value', '4');
expect(input).toHaveProperty('readOnly', true);
const input = screen.getByLabelText('项目版本') as HTMLInputElement;
expect(input.value).toBe('4');
expect(input.readOnly).toBe(true);
expect(screen.getByText('v4')).not.toBeNull();
expect(screen.getByText(/线上最近提交 v5 · 已公开/u)).not.toBeNull();
expect(screen.getByText(/线上最近提交 v3 · 已公开/u)).not.toBeNull();
await selectCover();
fireEvent.click(screen.getByRole('button', { name: '更新游戏' }));
await waitFor(() =>
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
);
const submittedVersion = vi.mocked(publishLocalProjectGame).mock
.calls[0]?.[0].versionNumber;
// 显示值 == 提交值 == 平台下一个版本号。
expect(submittedVersion).toBe(4);
// 本地迭代计数(1 条)与「本地计数 + 1」都不得成为提交值。
expect(submittedVersion).not.toBe(1);
expect(submittedVersion).not.toBe(2);
});
test('平台版本号不可得时显示占位、提交禁用,且不回落本地迭代计数', async () => {
installTauriInvoke(async () => undefined);
vi.mocked(readGameDistributionPublication).mockResolvedValue({
state: 'update',
binding: {
accountId: 'user-1',
apiBaseUrl: 'https://dev.test',
gameId: 'game_9',
revision: 7,
latestVersionId: 'gamever_2',
latestVersionNumber: 2,
status: 'pending_review',
},
// 有本地绑定但没能刷新线上状态:这一路没有平台版本数据。
refreshed: false,
draft: null,
visibility: null,
message: '无法刷新线上状态:无法连接登录服务',
});
renderPanel({
manifest: {
...MANIFEST,
// 本地 3 条内部版本:不得被当成版本号提交。
versions: [
{ versionId: 'a', parentVersionId: null, projectRevision: 1 },
{ versionId: 'b', parentVersionId: 'a', projectRevision: 2 },
{ versionId: 'c', parentVersionId: 'b', projectRevision: 3 },
],
} as unknown as GameCreationAppManifest,
});
const versionInput = (await screen.findByLabelText(
'项目版本',
)) as HTMLInputElement;
expect(versionInput.value).toBe('');
expect(versionInput.placeholder).toBe('—');
expect(
vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0].versionNumber,
).toBe(4);
screen.getByText('—', {
selector: '.game-distribution-publish-panel__version-tag',
}),
).not.toBeNull();
expect(
screen.getByText('无法刷新线上状态:无法连接登录服务'),
).not.toBeNull();
// 其余必填项补齐后,提交仍因版本号未知保持禁用。
fireEvent.change(screen.getByLabelText('游戏名称'), {
target: { value: '星轨防线' },
});
fireEvent.change(screen.getByLabelText('一句话简介'), {
target: { value: '守住轨道城' },
});
const submit = screen.getByRole('button', { name: '更新游戏' });
expect(submit).toHaveProperty('disabled', true);
fireEvent.click(submit);
expect(publishLocalProjectGame).not.toHaveBeenCalled();
});
test('恢复失败(网络/5xx)时禁用提交,不按首次发布继续', async () => {
@@ -113,9 +113,19 @@ describe('运行页「刷新运行画面」', () => {
const second = document.querySelector('iframe');
expect(second).not.toBe(first);
expect(new URL(String(first?.getAttribute('src'))).searchParams.get('__agc_reload')).toBe('1');
expect(new URL(String(second?.getAttribute('src'))).searchParams.get('__agc_reload')).toBe('2');
expect(new URL(String(second?.getAttribute('src'))).origin).toBe('http://127.0.0.1:4173');
expect(
new URL(String(first?.getAttribute('src'))).searchParams.get(
'__agc_reload',
),
).toBe('1');
expect(
new URL(String(second?.getAttribute('src'))).searchParams.get(
'__agc_reload',
),
).toBe('2');
expect(new URL(String(second?.getAttribute('src'))).origin).toBe(
'http://127.0.0.1:4173',
);
});
it('没有活预览时不渲染刷新,而不是留一个点了没反应的入口', async () => {
+26
View File
@@ -88,6 +88,32 @@ http {
}
# 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions/<token>/…` 是 sandbox iframe
# 的 src,包内相对资源沿同一前缀解析。它与通用 `/api` 同口径代理到 api-server,唯一差别是
# 清空 Cookie:播放会话不读账号凭证,而 api-server 播放网关对带平台 refresh Cookie 的请求返回 403。
# 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。
location ^~ /api/game-distribution/play-sessions/ {
default_type application/json;
client_max_body_size 210m;
limit_conn genarrative_api_conn 64;
limit_req zone=genarrative_api_rps burst=64 nodelay;
proxy_pass http://genarrative_api;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
add_header X-Accel-Buffering no always;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Request-Id $request_id;
proxy_set_header Cookie "";
}
location ~ ^/api(?:/|$) {
default_type application/json;
# 中文注释:创作接口会携带参考图 Data URL,游戏发行包 PUT 更大,Nginx 只负责放行到 api-server;
+8
View File
@@ -110,3 +110,11 @@ curl -sSI -H 'Accept-Encoding: br' \
- 隔离靠 iframe 沙箱而不是独立来源:游戏文档跑在 `sandbox="allow-scripts"` 的不透明来源里,读不到主站 Cookie、storage 与 DOM,离开页面即随 iframe 卸载。
- 审核通过时 `api-server` 按 gameId 派生同源路径 `/games/<gameId>/` 作为 `entryUrl` 写入公开投影,部署侧不再需要配置发行域名。换版本或下架只改变后端公开投影,边缘不需要改配置。
- 门禁:`npm run check:nginx-spa-routes` 校验三份模板的 SPA allowlist(含 `/creators`、`/creators/connections`、`/games`、`/games/detail`、`/games/play`、`/games/mine`、`/games/publish`、`/pay`、`/profile/payment`)与收银台深链前缀路由 `location ~* "^/pay/[^/]+/?$"`(`/pay/<checkoutToken>` 只放行「前缀 + 恰好一个路径段」;只放行裸前缀会让真实收银台链接落到默认 location 变 404),脚本自带正/反用例。历史上的独立来源模板与专属门禁已随同源方案上线删除。
## 付费游戏播放会话前缀(`/api/game-distribution/play-sessions/`)
- 付费游戏的可玩入口是 `POST /api/game-distribution/games/<gameId>/play-session` 换到的 `/api/game-distribution/play-sessions/<token>/`,直接作为 sandbox iframe 的 `src`;包内相对资源沿同一前缀解析。该前缀落在 `/api/*` 上,而通用 `/api` location 必须转发 Cookie(`/api/auth/*` 依赖 refresh cookie),Cookie 一旦被转发到 `api-server` 播放网关就会命中它的 403 纵深防御,iframe 与包内每个资源都不可用。
- 因此三份常驻模板(`genarrative.conf`、`genarrative-dev-http.conf`、容器 `deploy/container/nginx.conf`)都在通用 `/api` location 之前内联 `location ^~ /api/game-distribution/play-sessions/`,代理头、`client_max_body_size 210m`、`limit_conn` / `limit_req`、超时与维护判断都与通用 `/api` location 一致,只多一条 `proxy_set_header Cookie ""`。
- `^~` 不能省:不加时 nginx 会先命中正则 location `~ ^/api(?:/|$)`,Cookie 又被转发回去。前缀末尾的斜杠只影响裸前缀 `/api/game-distribution/play-sessions`(无 token):该形态继续走通用 `/api`,而 api-server 在这个前缀下只注册了 GET 入口与包内资源,创建会话是 `POST /api/game-distribution/games/<gameId>/play-session`,不在此前缀下,因此不存在「带账号凭证却落在清 Cookie 前缀里」的请求;`/play-sessions/<token>` 与 `/play-sessions/<token>/…` 都落在该前缀内、都会被清 Cookie。
- 本地 dev 由 `vite.config.ts` 里排在 `/api/game-distribution` 之前的同名前缀规则做同一件事(`proxyReq.removeHeader('cookie')`);`api-server` 播放网关的 403 纵深防御不放宽,只保证边缘/dev 转发时不带 Cookie。
- 门禁:`npm run check:nginx-spa-routes` 对三份模板断言该 `^~` 前缀 location 存在、清空 Cookie、代理头齐全且排在通用 `/api` location 之前;`npm run check:pingora-route-parity` 断言矩阵里的 `play_sessions_gateway` 用例(以及 Pingora 的 `RouteDecision::PlaySessionGateway`)指向独立的清 Cookie 转发,不会被合并回通用 `/api` 规则。
+34
View File
@@ -116,6 +116,40 @@ server {
}
# 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions/<token>/…` 是 sandbox iframe
# 的 src,包内相对资源沿同一前缀解析。它与通用 `/api` 同口径代理到 api-server(大小上限、
# 限流、超时、维护判断都保持一致),唯一差别是清空 Cookie:播放会话不读账号凭证,而
# api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留)。
# 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。
# 只匹配带尾斜杠的前缀:`/play-sessions/<token>` 与 `/play-sessions/<token>/…` 都落在该前缀内,
# 裸 `/api/game-distribution/play-sessions` 仍走通用 `/api`。该前缀下 api-server 只注册 GET
# 入口与包内资源;创建会话是 `POST /api/game-distribution/games/<gameId>/play-session`,不在此前缀下。
location ^~ /api/game-distribution/play-sessions/ {
default_type application/json;
client_max_body_size 210m;
limit_conn genarrative_api_conn 64;
limit_req zone=genarrative_api_rps burst=64 nodelay;
if ($genarrative_maintenance) {
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
}
proxy_pass http://genarrative_api;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
add_header X-Accel-Buffering no always;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Request-Id $request_id;
proxy_set_header Cookie "";
}
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
location ~ ^/api(?:/|$) {
default_type application/json;
+34
View File
@@ -144,6 +144,40 @@ server {
include /etc/nginx/snippets/genarrative-host-extras.conf;
# 平台付费游戏播放会话入口:`/api/game-distribution/play-sessions/<token>/…` 是 sandbox iframe
# 的 src,包内相对资源沿同一前缀解析。它与通用 `/api` 同口径代理到 api-server(大小上限、
# 限流、超时、维护判断都保持一致),唯一差别是清空 Cookie:播放会话不读账号凭证,而
# api-server 播放网关对带平台 refresh Cookie 的请求返回 403(纵深防御保留)。
# 前缀 location 必须写 `^~`:不加时正则 location `~ ^/api(?:/|$)` 会先命中,Cookie 又会被转发。
# 只匹配带尾斜杠的前缀:`/play-sessions/<token>` 与 `/play-sessions/<token>/…` 都落在该前缀内,
# 裸 `/api/game-distribution/play-sessions` 仍走通用 `/api`。该前缀下 api-server 只注册 GET
# 入口与包内资源;创建会话是 `POST /api/game-distribution/games/<gameId>/play-session`,不在此前缀下。
location ^~ /api/game-distribution/play-sessions/ {
default_type application/json;
client_max_body_size 210m;
limit_conn genarrative_api_conn 64;
limit_req zone=genarrative_api_rps burst=64 nodelay;
if ($genarrative_maintenance) {
return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}';
}
proxy_pass http://genarrative_api;
proxy_http_version 1.1;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
add_header X-Accel-Buffering no always;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Request-Id $request_id;
proxy_set_header Cookie "";
}
# 临时兼容主站仍在使用的 /api/* HTTP facade;前端完成 SpacetimeDB SDK 迁移后删除。
location ~ ^/api(?:/|$) {
default_type application/json;
@@ -366,6 +366,27 @@
},
"docs": ["/games/game_<32 位十六进制 id>/…", "平台同源发行入口"]
},
{
"id": "play_sessions_gateway",
"samplePath": "/api/game-distribution/play-sessions/token_1/index.html",
"expect": {
"kind": "play_session_gateway",
"protectionClass": "api"
},
"nginx": {
"production": [
"location ^~ /api/game-distribution/play-sessions/",
"proxy_set_header Cookie \"\";",
"proxy_pass http://genarrative_api;"
],
"development": [
"location ^~ /api/game-distribution/play-sessions/",
"proxy_set_header Cookie \"\";",
"proxy_pass http://genarrative_api;"
]
},
"docs": ["平台付费游戏播放会话入口", "/api/game-distribution/play-sessions/<token>/…"]
},
{
"id": "web_spa_case_trailing_slash",
"samplePath": "/PROJECT/",
+1
View File
@@ -26,6 +26,7 @@
- [后台游戏评价管理合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#后台游戏评价管理合同):查找、分页、隐藏/恢复/删除、必填原因、统计与个人状态联动;已实现并通过本地验证,待用户验收,未部署。
- [后台游戏评价管理里程碑](./project-memory/plans/【里程碑】后台游戏评价管理-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】后台游戏评价管理-2026-10-01.md):单里程碑范围、接口/schema 边界及验收要求;本地证据已回写主规范。
- [游戏广场评分展示合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏广场评分展示合同)、[里程碑](./project-memory/plans/【里程碑】游戏广场评分展示-2026-10-01.md)与[实施计划](./project-memory/plans/【实施计划】游戏广场评分展示-2026-10-01.md):已实现并通过本地定向验证,待用户验收,未部署;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。
- [游戏买断制泥点付费与播放鉴权合同](./【玩法创作】平台入口与玩法链路-2026-05-15.md#游戏买断制泥点付费与播放鉴权合同)与[里程碑](./project-memory/plans/【里程碑】游戏买断制泥点付费与播放鉴权-2026-10-05.md):已实现,本机真实栈 E2E 由人工验收脚本 `check:game-distribution-purchase-e2e` 覆盖(最近一次人工运行 76 PASS / 0 FAIL / 1 WARN),该脚本不在 CI 自动门禁内;待用户验收,未部署;作者可选买断制泥点付费,购买后永久可玩,后台审核可见价格且审核员不限次试用;网页与 AGC 两个发布入口一致支持定价并共用 `packages/shared` 组件 `PlatformGamePricingField`,AGC 定价的前端用例与 Rust 预填单测已覆盖,AGC 真实栈发布未覆盖。
- [游戏游玩次数计数](./adr/【ADR】游戏游玩次数计数-2026-10-03.md):点「开始游戏」前端上报一次游玩,api-server 纯内存聚合(5s flush、30min 去重、`IP+game` 限流、关停不强制 flush),批量 procedure 自增现有 `game_distribution_game.play_count`,不 bump `updated_at`。
- [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md)
- [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。
@@ -0,0 +1,69 @@
# 【里程碑】游戏买断制泥点付费与播放鉴权
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | implemented-awaiting-runtime-acceptance |
| Date | 2026-10-05 |
| Parent Spec | `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(「游戏买断制泥点付费与播放鉴权合同」章) |
## 目标
把游戏分发从“公开即免费可玩”扩展为“作者可选买断制泥点付费;其他用户购买后永久可玩;后台审核可见价格且审核员不限次试玩”。
## 范围
- 作者端定价:网页 `/games/publish` 与 AGC 发布面板在新建与更新模式选择“免费 / 买断制 N 泥点”;两入口共用 `packages/shared` 定价组件 `PlatformGamePricingField`,AGC 壳请求透传 `priceMudPoints`;给已上线作品发新版本时预填价格与模式,作者不改则价格不变。预填价口径:取该作品**最新版本的冻结价**(该版本可能是 `pending_review` / `rejected` 状态的版本,因此预填值可能尚未生效),仅当该冻结价缺失或为 0 时才回落作品行当前价,两条来源都取不到才按免费(0)。AGC 壳侧实现 `resolve_publication_prefill_price`(`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:811`);网页侧读版本详情 `detail.version.priceMudPoints ?? detail.game.priceMudPoints ?? 0`(`src/components/game-distribution/GamePublishPage.tsx:164`),后端 `version_detail_payload` 已把版本详情的 `game.priceMudPoints` 一并归一为同一冻结价口径(`server-rs/crates/api-server/src/modules/game_distribution.rs:3797`)。
- 玩家端:公开详情展示价格与购买态、泥点购买、购买后游玩。
- 付费游玩鉴权:未购买不得游玩,直连公开发行路径必须失败。
- 泥点钱包扣费与 `game_purchase` 流水。
- 后台审核详情与待审列表展示价格。
## 不在范围内
- 创作者分成与结算、退款/撤销购买、促销/限时免费、订阅或游戏内购、价格运营审批流。
- 评分/评论与付费的耦合、外部 OpenAPI。
- 免费游戏现有链路的任何行为变化。
## 依赖与前置条件
- 现有游戏分发状态机、审核 CAS/幂等与版本冻结资料机制。
- runtime profile 钱包的原子写与 `profile_wallet_ledger` 流水。
- 现有发行网关 sandbox/CSP/Cookie 约束,以及后台待审版本预览会话(token 前缀承载相对资源)模式。
- 主规范“游戏买断制泥点付费与播放鉴权合同”与本文档评审通过。评审前不写业务代码。
## 验收标准
- [x] 作者网页发布可选「免费」或「买断制 N 泥点」;负数/超上限/非整数被前后端同时拦截。证据:真实本地栈 E2E 价格 `1000001` → 400 且不落版本,`0` / `1000000` → 200;`cargo test -p module-game-distribution` 29 passed、`cargo test -p api-server game_distribution` 53 passed 覆盖定价校验,网页发布表单定价定向 Vitest 覆盖前端拦截。
- [ ] AGC 发布面板与网页一致可选「免费 / 买断制 N 泥点」,非法值被前后端同时拦截;给已上线作品发新版本时按「最新版本冻结价优先、缺失或为 0 时才回落作品行当前价」预填价格与模式,作者不改则价格不变。
- 已覆盖:AGC 发布面板定价定向 Vitest `apps/ai-game-creator-shell/tests/gameDistributionPublishPanel.test.tsx`(`:333` 默认免费提交 `priceMudPoints=0`;`:342` 买断制先本地校验:空值与 `1000001` 分别报「买断价必须是整数泥点」「买断价必须是 1 到 1000000 之间的整数泥点」且都不发发布请求,`120` 随版本提交 `priceMudPoints=120`;`:802` 历史响应没有回读价格时按免费预填;`:819` 更新模式预填线上买断价 240、作者不改仍按 240 提交);AGC 壳请求层校验定向 Vitest `apps/ai-game-creator-shell/tests/gameDistributionPublish.test.ts`(`:180` 透传 `priceMudPoints=240`;`:197` `1000001` / `1.5` 在 native command 之前失败关闭);预填口径 Rust 单测 `apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs:1715`(`publication_prefill_price_prefers_latest_frozen_price_and_falls_back`:最新版本冻结价 300 优先于作品行 240、历史版本无冻结价回落 240、免费作品与缺字段按 0);两入口共用 `packages/shared` 组件 `PlatformGamePricingField`。
- **未覆盖,故本项保持未勾选**:AGC 真实栈「发布付费版本」未手点一次;真实本地栈 E2E 只覆盖网页发布入口的定价链路(`1000001` → 400 且不落版本、`0` / `1000000` → 200)。
- [x] 后台审核详情展示该版本价格;无价格的历史版本按免费展示。证据:`cargo test -p api-server game_distribution` 53 passed(`private_version_payload` / `version_detail_payload` 输出 `priceMudPoints`,历史无价格版本按 0);admin-web 审核价格展示定向 Vitest。
- [x] 审核员不限次数试玩待审付费版本,不校验购买、不扣泥点。证据:真实本地栈 E2E 审核员两次试玩待审付费版本均 200、两次包内字节一致,作者与审核员余额/流水均不变。
- [x] 未购买用户详情可见资料但无可播放入口;直连 `/games/<gameId>/` 返回 404。证据:真实本地栈 E2E 详情 `priceMudPoints=30` / `purchased=false` / `entryUrl=null`,发行网关与平台同源均 404,创建会话 403,未登录 401。
- [x] 余额不足购买失败,余额、账单与购买记录都不变。证据:真实本地栈 E2E 返回 400 `INSUFFICIENT_MUD_POINTS`,余额与流水不变。
- [x] 购买成功:余额减少 N、账单出现 `game_purchase` 流水、购买记录唯一、可反复游玩不再扣费。证据:真实本地栈 E2E 扣 30(120→90),`game_purchase` 流水与购买行各 1 条,重复进入不再扣费。
- [x] 双击、并发与响应丢失重试只扣一次。证据:真实本地栈 E2E 同 key 重放、换 key 重复购买均不再扣费;两路不同 key 真并发返回 200/200、只有一个 `replayed=false`、余额只扣一次、购买记录 1 条。
- [x] 作者本人与管理员免购买即可游玩付费作品且不扣费。证据:真实本地栈 E2E 作者(自购 400 `GAME_PURCHASE_OWNER_EXEMPT`、余额/流水不变、播放会话 200)与管理员(管理员令牌创建播放会话 200、入口 200);管理员免购买经 play-session 管理员分支实现。
- [x] 免费游戏的发布、详情、游玩与计数链路无行为变化。证据:真实本地栈 E2E 免费游戏详情保留入口、发行网关 200、匿名创建会话 200 且不签发令牌。
## 未覆盖边界(保持未勾选)
- [ ] 管理员安全下架后的播放会话失效:本轮未单独运行,与作者下架共用同一 404 关闭路径。
- [ ] 多账号 / 多游戏混合并发购买:本轮只覆盖单游戏、单账号的两路真并发。
## 证据要求
- 自动化:Rust 定向测试(定价校验、购买幂等与并发、授权判定、状态机)、Vitest(发布表单定价、详情购买态、游玩页会话挂载、审核价格展示)、schema/绑定与编码门禁。
- 运行时:真实本地栈跑通「发布付费游戏 → 后台审核可见价格并试玩 → 通过 → 未购买访问失败 → 购买 → 游玩 → 重复游玩不扣费」。
- 边界:并发购买、余额不足、下架/封禁后播放会话失效、猜测 URL 直连、免费游戏回归。
## 验收证据摘要(真实栈 E2E)
- 验收命令:`npm run check:game-distribution-purchase-e2e`(等价于 `node scripts/check-game-distribution-purchase-e2e.mjs`,脚本入口见 `package.json:92`)。
- 断言总数:该脚本内共 76 处 `check(...)` 调用;`check(name, ok, detail)`(脚本 `:86`)每条打印一行 `PASS` / `FAIL`,因此 76 即本轮断言总数。
- 最近一次人工运行结论(2026-10-05,本机真实 SpacetimeDB / api-server / OSS):**76 PASS / 0 FAIL / 1 WARN**(0 FAIL,进程退出码 0)。本节只记可复核的文本结论,不记耗时秒数。
- 唯一 WARN:「管理员令牌购买」在现役登录链路下不可达 —— 后台管理员令牌在 `/api/*` 用户路由上先被 `require_bearer_auth` 判为无效登录态,走 401 前置,未进入 403 `GAME_PURCHASE_ADMIN_NOT_ALLOWED` 分支(该分支作为纵深防御保留);管理员免购买由 play-session 管理员分支覆盖(脚本 `:1122`-`:1173`)。
- WARN 语义:脚本的 `warn()`(`:91`)表示环境条件不满足而**跳过**的断言(例如未发现主站 Vite、`spacetime sql` 直查不可用),不是失败。
- 脚本定位:**人工验收脚本,不在 CI 自动门禁内**(需真实 SpacetimeDB + api-server 与 `E2E_ADMIN_USER` / `E2E_ADMIN_PASSWORD` 环境变量),CI 绿不代表该链路已验证。
- 交付状态:本里程碑结论最高到「本机真实栈验证通过,待用户验收,未部署」。
@@ -1,5 +1,16 @@
# 决策记录
## 2026-10-05 播放会话前缀在三处入口清空 Cookie,网关 403 纵深防御不变
- 背景:付费游戏的可玩入口是创建播放会话后拿到的 `/api/game-distribution/play-sessions/<token>/`(sandbox iframe 的 `src`,包内相对资源沿同一前缀解析)。该前缀落在 `/api/*` 上,边缘通用 `/api` location 必须转发 Cookie(`/api/auth/*` 需要 refresh cookie),而 `api-server` 播放网关对带可解析平台 refresh Cookie 的请求返回 403,导致真实浏览器里 iframe 与每个包内资源都 403、付费游戏实际不可玩。
- 决策(边缘):三份 nginx 模板(`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf`)在通用 `/api` location 之前内联 `location ^~ /api/game-distribution/play-sessions/`;代理头、`client_max_body_size 210m`、`limit_conn` / `limit_req`、超时与维护判断与通用 `/api` 保持一致,额外清空 Cookie。`^~` 必填(否则正则 location `~ ^/api(?:/|$)` 优先命中),且只匹配带尾斜杠的前缀。
- 决策(dev):`vite.config.ts` 在 `/api/game-distribution` 之前加同名前缀代理规则,`proxyReq.removeHeader('cookie')`。
- 决策(网关):`server-rs/crates/pingora-gateway` 新增 `RouteDecision::PlaySessionGateway`,与通用 `/api` 同口径(api 上游、api 限流分组、大小上限、维护闸),差别只在经新增的 `route_clears_cookie` 清空 Cookie(发行入口 `ReleaseGateway` 复用同一判定)。**不放宽** `api-server` 的 Cookie 拒绝。
- 决策(边界):前缀只认带尾斜杠的形式,创建会话的 `POST /api/game-distribution/play-sessions` 与 `POST /api/game-distribution/games/{gameId}/play-session` 继续走通用 `/api` 并保留 Cookie。
- 门禁:矩阵新增 `play_sessions_gateway` 用例;`check:nginx-spa-routes` 新增「该前缀 location 存在、清空 Cookie、排在通用 `/api` 之前」断言,`check:pingora-route-parity` 新增「矩阵用例必须声明清 Cookie 且不得复用通用 `/api` location / Rust 播放会话分支必须排在通用 `/api` 之前」断言,`check:pingora-gateway-smoke` 新增真实网关下「该前缀清 Cookie、创建会话端点保留 Cookie」用例。
- 影响面:`deploy/nginx/{genarrative.conf,genarrative-dev-http.conf,README.md}`、`deploy/container/nginx.conf`、`vite.config.ts`、`server-rs/crates/pingora-gateway/src/main.rs`、`deploy/pingora/nginx-route-parity.matrix.json`、`scripts/check-{nginx-spa-routes,pingora-route-parity,pingora-gateway-smoke}.mjs`、`docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
- 关联:`docs/project-memory/shared-memory/pitfalls.md`「付费游戏播放会话前缀落在 `/api/*`」条。
## 2026-10-03 每日免费发放额为 0 时前端隐藏该池
- 背景:运营需要一个可逆的「不提供每日免费泥点」状态。不给它新增 `retired` 状态位或新字段,直接把后台配置 `daily_free_points_per_day` 配成 0,让「每日免费发放额」这个普通数值自己表达;前端据此隐藏每日免费相关入口。
@@ -9,7 +20,6 @@
- 影响范围:`packages/shared/src/utils/mudPoints.ts`、`PlatformMudPointWalletEntry`、`PlatformProfileRechargeModal`(池概览 3 列变 2 列、扣点顺序提示与泥点确认页文案随可见性切换)、`apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx`(每日免费允许填 0)、`server-rs/crates/module-runtime/{commands,errors,lib}.rs`;`daily_free_grant` / `daily_free_reset` 账本文案保留,历史流水不改写。
- 验证:`cargo check -p module-runtime`、`cargo test -p module-runtime profile_wallet_config_allows_zero_daily_free_points`、`cargo fmt --check`、根 `npm run typecheck`、`npm run admin-web:typecheck`、共享层与 admin 定向 vitest 22/22、`npm run check:encoding`、`git diff --check` 通过。
## 2026-10-05 创作者主页与关注粉丝的产品边界
- 产品已确认:桌面第四项“创作者主页”默认进入当前账号主页,“我的”移到第五项;他人的关注/粉丝列表公开可查看,自己或他人的两类列表均可点击用户进入其创作者主页。
@@ -2,6 +2,24 @@
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
## 2026-10-05 给 `shared-contracts` 请求 DTO 加字段:`#[serde(default)]` 救不了 Rust 结构体字面量,AGC 壳编译失败会伪装成 `npm run dev:all` 起不来
- **现象**:`npm run dev:all` 的根栈五个服务与 AGC Vite 都就绪、终端也会打印启动汇总,但 AGC 窗口始终不出现;stdout 里夹着 `error[E0063]: missing field `price_mud_points` in initializer of `GameDistributionCreateVersionRequest``(`src\game_distribution_publish.rs:1356`)和 `error: could not compile `genarrative-ai-game-creator-shell` (bin ...) due to 1 previous error`。编译失败不会让 `tauri dev` 退出,dev:all 只会一直挂着(`waitForAgcFrontend` 的 660s 预算耗尽后才收束整棵树),容易被误判成端口占用或根栈没起来。
- **根因**:变更在 `server-rs/crates/shared-contracts/src/game_distribution.rs` 给请求 DTO 末尾追加了 `#[serde(default)] pub price_mud_points: u64`。`serde(default)` 只影响**反序列化缺字段**,Rust **结构体字面量仍必须列全字段**。AGC 壳 `apps/ai-game-creator-shell/src-tauri` 是**独立 cargo workspace**(自带 `[workspace]`,只把 `shared-contracts` 当 path 依赖),`server-rs` 侧的 `cargo test -p ...` 六个 crate 全绿也覆盖不到它。
- **处理(现行口径)**:改 `shared-contracts` 里**请求 DTO 字段**时,必须同一次编译 AGC 壳:先 `npm run agc:bundled-resources:prepare`(`build.rs` 只做只读校验,未准备资源会以「随包插件存在未声明文件」失败关闭),再 `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute`(约 30s,feature 与 dev/release 对齐);完整口径是 `npm run ai-game-creator-shell:check:rust`。定价字段的取值口径分两段:**历史口径**(2026-10-05 之前)AGC 发布面板没有买断制定价入口,构造版本请求时显式按免费提交 `price_mud_points: 0`,与升级前行为一致;**现行口径** AGC 发布面板已支持「免费 / 买断制」定价,请求按作者选择从载荷透传价格,`#[serde(default)]` 仅用于向后兼容不传该字段的旧客户端(按免费处理)。
- **边界**:`cargo check` **不能省 feature 参数**——默认 feature 下 `build.rs` 会因为已 staging 的 godot/unity 插件 DLL「不在当前 feature 组合的声明清单里」而红,那是资源准备前置条件,不是本次代码的编译错误。门禁本身**已经存在**:CI `.gitea/workflows/project-ci.yml` 的 `AI game creator shell Rust lane 1/2`、`lane 2/2` 各顺序跑两片 `npm run check:native-shells:agc-rust-shard-N`(底层 `ai-game-creator-shell:check:rust:shell` → `cargo test --locked` 编出壳 bin),本地同序列是 `npm run check:native-shells`。漏拦的原因是本地验证只跑了 `server-rs` 侧 crate、没等 MR/CI 上的 AGC lane,不是缺门禁;因为 2466 条 bin 单测跑满很贵,**不要**把整条 lane 塞进常用本地命令,只在改动 shared-contracts 请求 DTO 时补上面的 `cargo check`。
- **判据/取证**:E0063 原文(`logs/dev-all-feature.log`);修复后 `tauri dev` watcher 重建输出 `Finished `dev` profile [unoptimized + debuginfo] target(s) in 38.16s` 并拉起 `target\debug\genarrative-ai-game-creator-shell.exe`;同一 feature 组合 `cargo check` 退出码 0;`.app/dev-stack.json` 记录的实际端口下 api-server `/healthz`、主站 `/`、admin-web `/admin/`、AGC marker `/__agc_dev_server.json` 均 200。
- **关联**:`server-rs/crates/shared-contracts/src/game_distribution.rs`、`apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs`、`.gitea/workflows/project-ci.yml`、`scripts/check-native-shells.mjs`。
## 2026-10-05 付费游戏播放会话前缀落在 `/api/*`:边缘转发 Cookie 会让 iframe 与包内每个资源都 403
- **现象**:付费游戏在真实浏览器里打不开——播放会话 `src`(`/api/game-distribution/play-sessions/<token>/`)本身和包内每个相对资源(JS/CSS/图片/音频)全是 403,同一份包的免费游戏 `/games/<gameId>/` 正常。
- **原因**:播放会话前缀落在 `/api/*` 上,而通用 `/api` location 必须转发 Cookie(`/api/auth/*` 依赖 refresh cookie);`api-server` 播放网关对带**可解析平台 refresh Cookie** 的请求返回 403(与发行网关同族的纵深防御,本次不放宽),于是沙箱 iframe 的每个同前缀请求都带 Cookie、都被拒。dev 侧同样复现:`vite.config.ts` 原本只对 `/games/...` 清 Cookie,`/api/game-distribution` 规则会转发 Cookie。
- **处理(现行口径)**:三份 nginx 模板(`deploy/nginx/genarrative.conf`、`deploy/nginx/genarrative-dev-http.conf`、`deploy/container/nginx.conf`)在通用 `/api` location **之前**加 `location ^~ /api/game-distribution/play-sessions/`——`^~` 不能省,否则正则 location `~ ^/api(?:/|$)` 优先命中、Cookie 又被转发;代理头 / `client_max_body_size 210m` / `limit_conn` / `limit_req` / 超时 / 维护判断都与通用 `/api` 一致,只多一条 `proxy_set_header Cookie ""`。`vite.config.ts` 在 `/api/game-distribution` 之前加同名前缀规则(`proxyReq.removeHeader('cookie')`)。Pingora 侧对应 `RouteDecision::PlaySessionGateway`:路径原样走 api 上游,同样套 api 限流分组、大小上限与维护闸,差别只在新增的 `route_clears_cookie` 清空 Cookie(发行入口 `ReleaseGateway` 复用同一判定)。
- **边界**:前缀**只匹配带尾斜杠**的形式——创建会话的 `POST /api/game-distribution/play-sessions`(以及 `POST /api/game-distribution/games/{gameId}/play-session`)需要账号凭证,必须继续走通用 `/api` 并保留 Cookie。网关的 403 拒绝保持不变,只在边缘/dev 保证请求不带 Cookie。
- **门禁**:`npm run check:nginx-spa-routes` 对三份模板断言该 `^~` location 存在、块内清空 Cookie、代理头齐全且排在通用 `/api` location 之前(变异验证:删掉块内 `proxy_set_header Cookie "";` 立刻报「播放会话前缀 location 缺少代理片段」);`npm run check:pingora-route-parity` 断言矩阵 `play_sessions_gateway` 用例声明清 Cookie 片段、不复用通用 `/api` location,且 Rust `classify_path` 的播放会话分支排在通用 `/api` 之前(变异验证:把矩阵片段换成通用 location、或在 Rust 里交换两个分支,各自单独判红);`npm run check:pingora-gateway-smoke` 用真实网关二进制断言该前缀清 Cookie、创建会话端点保留 Cookie。三条都串在 `npm run lint` 链里,有自动调用方。
- **关联**:`server-rs/crates/pingora-gateway/src/main.rs`、`deploy/pingora/nginx-route-parity.matrix.json`、`deploy/nginx/README.md`、`vite.config.ts`;另见本文件「主站 SPA allowlist 有三处真相源」条的「别踩」(发行入口不转发 Cookie 的同族规则)。
## 2026-10-05 自定义作者插槽应保留昵称降级语义
- 游戏公开投影里的「创作者」「未知作者」是角色占位词。作者昵称 hook 已将加载中和查询失败分别转成 null 与空串,宿主不能再用 `|| game.author.name` 把占位词补回。
@@ -532,6 +532,7 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清
| `/admin/*` | 先读取静态文件或目录 index,失败回退 `/admin/index.html`,HTML 默认 `no-cache`,并支持条件请求返回 `304` 与单段 `Range: bytes=` 返回 `206` / 越界返回 `416`。 |
| `/assets/*` | 从 Web 根目录精确读取静态文件;带 Vite 指纹的文件默认长期缓存,其它文件默认 `no-cache`,并支持条件请求返回 `304` 与单段 `Range: bytes=` 返回 `206` / 越界返回 `416`。 |
| `/api`、`/api/*` | 转发到 `api-server`,按配置执行 `Content-Length` 与流式 body 累计上限检查。 |
| `/api/game-distribution/play-sessions/*` | 付费游戏播放会话入口,转发到 `api-server`;与 `/api/*` 同样限流、大小上限与维护判断,额外清空 `Cookie`(详见下节)。 |
| `/v1/database/{db}/subscribe`、`/v1/identity*` | 转发到 SpacetimeDB,保留 WebSocket Upgrade 头。 |
| `/__genarrative_pingora/healthz` | 仅在携带 `X-Genarrative-Pingora-Probe` 且匹配配置 token 时返回 shadow JSON,否则 404。 |
| `/v1/*`、`/generated-*`、`/healthz*`、`/readyz*` | 返回 404,保持生产公网不暴露口径。 |
@@ -545,6 +546,8 @@ SPA allowlist 里属于游戏分发入口的深链(游戏目录 / 详情 / 游
**平台同源发行入口**(`/games/game_<32 位十六进制 id>/…`)与 SPA allowlist 是两条不同的路由:Nginx 用 `location ~ "^/games/(?<game_id>game_[0-9a-f]{32})(?<game_path>/.*)?$"` 把它代理到 api-server 的发行网关(`proxy_set_header Cookie ""` + `proxy_pass .../api/game-distribution/releases/$game_id$game_path`),Pingora 侧对应 `RouteDecision::ReleaseGateway`:走 api 上游,但把上游路径重写成 `/api/game-distribution/releases/<gameId><asset 路径>`(与 Nginx 的 `proxy_pass` 同口径,原来的 query 不再拼接)、清空 `Cookie`,并按 Nginx 该 location 的语义既不进 SPA fallback、也不套用 `limit_conn` / `limit_req` 分组、不受维护闸拦截。只认小写、固定 32 位十六进制 id;`/games/detail` 这类 SPA 深链与 `/games/game/...` 这类形状不符的路径都不会被吞进发行网关。该口径由矩阵的 `games_release_gateway` 用例(含 `upstreamPath` 期望值)与 `cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 固定。
**平台付费游戏播放会话入口**(`/api/game-distribution/play-sessions/<token>/…`,sandbox iframe 的 `src`,包内相对资源沿同一前缀解析)与通用 `/api` 路由只差一件事:转发时必须清空 `Cookie`。api-server 播放网关对带可解析平台 refresh Cookie 的请求返回 403(纵深防御,保留),Cookie 一旦被边缘转发,iframe 与包内每个相对资源都会 403,付费游戏实际不可玩(2026-10-05 就是这样暴露的)。因此 Nginx 三份模板都加 `location ^~ /api/game-distribution/play-sessions/`(`^~` 不能省,否则正则 location `~ ^/api(?:/|$)` 优先命中、Cookie 又被转发),代理头、`client_max_body_size`、`limit_conn` / `limit_req`、超时与维护判断都与通用 `/api` location 一致,只是多了 `proxy_set_header Cookie ""`;dev 侧 `vite.config.ts` 用同名前缀规则在通用 `/api/game-distribution` 之前清 Cookie。Pingora 侧对应 `RouteDecision::PlaySessionGateway`:路径原样走 api 上游、同样套用 api 限流分组、大小上限与维护闸,唯一差别是经 `route_clears_cookie` 清空 `Cookie`。前缀只匹配带尾斜杠的形式,创建会话的 `POST /api/game-distribution/play-sessions` 仍走通用 `/api` 并保留 Cookie。该口径由矩阵的 `play_sessions_gateway` 用例、`cargo test -p pingora-gateway matches_nginx_route_parity_matrix` 与 `npm run check:nginx-spa-routes` 里的播放会话 Cookie 隔离断言固定。
维护模式下,公网 API-like 路由返回 JSON `503`;公网 Web 静态路由先读取 `GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE` 指向的 release 外运行态公告,缺失时回退 `GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT/maintenance.html`,两者都不存在时返回纯文本 `503`。版本化默认页不得包含日期或具体时段,临时公告由 `maintenance-on.sh --page-file` 安装并在 `maintenance-off.sh` 时清理。IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local 来源绕过整站维护闸,主站页面与静态资源、普通 API、后台页面与后台 API、SpacetimeDB 路由均按非维护状态继续处理;应用层登录、管理员鉴权和其它业务鉴权保持不变。Pingora 直连按 TCP peer 判定来源;仅当 peer 是 loopback 的同机 Nginx 时才接受 Nginx 强制覆盖的 `X-Real-IP`,绝不使用客户端可伪造的 `X-Forwarded-For` 做维护放行。该放行只绕过网关维护响应;若 `pause-after-stdb` 已停止 api-server,内网普通 API 和后台 API 仍不可用。
代理失败时,API / SpacetimeDB 等代理路由返回统一 JSON 网关错误;本地静态路由仍保持对应 HTTP 错误状态。
静态 `Range` 只支持单段 bytes range;多段 range 暂按完整文件返回,避免在正式替换前引入 multipart 响应面。`If-None-Match` / `If-Modified-Since` 优先于 `Range` 判定,命中时仍返回 `304`;`If-Range` 日期匹配时继续返回 `206`,日期旧于文件或弱 ETag 校验器时回完整 `200`;`206` / `304` / `416` 不做 gzip 压缩,避免 `Content-Range` 语义被响应体改写破坏。Gateway smoke 会用固定 `X-Request-Id` 对账静态 `304`、`405`、`206`、`416` 的 Pingora access log 行,确认本地响应状态也进入正式切换证据链。

Some files were not shown because too many files have changed in this diff Show More