作者本人(owner=1)一律算共创者侧并带上来源标记;游玩页返回改为按来处(新参数 backTo=站内路径,含刷新/深链,站外值拦掉)
Project CI / Backend tests (pull_request) Failing after 34s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m24s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m8s
Project CI / Repository checks (pull_request) Failing after 40s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m26s
Project CI / Frontend tests (pull_request) Successful in 3m5s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m57s
Project CI / Native shell tests (pull_request) Successful in 9m14s

This commit is contained in:
2026-10-07 20:28:13 +08:00
parent dfded78508
commit 3b59ab3d26
4 changed files with 119 additions and 22 deletions
@@ -553,12 +553,16 @@ function GameDetailContent({
}) {
/**
* 观看者所处的**入口上下文**(2026-10-07 产品口径更正:游客 = 点开游戏界面的玩家,
* 不是「未登录访客」):由导航层写在 URL 上的来源决定,**与登录态无关**。
* 不是「未登录访客」):由导航层写在 URL 上的来源决定,**与登录态无关**;
* 作者本人进自己的作品(`?owner=1`)**一律算共创者侧**。
* **判据只有一处**(`resolveGameViewerContext` + `isCoCreator`,见该模块的说明),
* 页面上所有差异都用这一个布尔量驱动,不在 JSX 里散写来源判断。
*/
const coCreator = isCoCreator(
resolveGameViewerContext(readDetailEntrySource()),
resolveGameViewerContext({
source: readDetailEntrySource(),
isAuthorViewer: ownerView,
}),
);
/**
* 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架
@@ -37,6 +37,30 @@ export const CO_CREATION_ORIGIN_STAGES: readonly string[] = [
export type GameViewerContext = 'player' | 'co-creator';
/**
* 游玩页 URL 上的**返回目标**参数:`backTo=<encodeURIComponent(站内路径)>`。
*
* 语义(2026-10-07 用户口径「看他从哪来的」):进入游玩页时把**来处**记在 URL 上,游玩页的
* 「返回」按钮(以及浏览器返回的兜底)据此回到来处,刷新/深链都不丢。
* 只接受**站内相对路径**(单个 `/` 开头),绝对 URL / 协议相对 `//host` / 畸形值一律忽略 ⇒ 调用方走兜底。
* 它只表达「回哪」,**不**表达「哪一侧」——哪一侧仍然只由 `from` 决定,不新造第二套来源口径。
*/
export const PLAY_BACK_TO_QUERY_PARAM = 'backTo';
/**
* 校验并归一化 `backTo`:返回可直接用于站内跳转的路径,或 `null`(不合法 ⇒ 调用方走兜底)。
* 防开放重定向:只放行 `/[^/]...` 这种单斜杠开头的站内路径。
*/
export function resolveInternalBackTo(
raw: string | null | undefined,
): string | null {
const value = (raw ?? '').trim();
if (!value.startsWith('/') || value.startsWith('//')) return null;
if (/[\r\n\t]/u.test(value)) return null;
if (value === '/') return null;
return value;
}
/**
* 「已经在共创侧」的页面:**继续往下跳(或原地刷新)时要把来源标记带着走**,
* 别中途掉回玩家侧。
@@ -55,11 +79,23 @@ function isCoCreationSource(source: string | null | undefined) {
/**
* 来源标记 → 观看者所处的一侧。**缺省 / 未知一律 = 玩家侧**。
*
* `isAuthorViewer`(观看者就是这部作品的作者,即 `GameDetailPage` 的 `ownerView`,来自 `?owner=1`)
* **一律算共创者侧**(2026-10-07 产品拍板):作者从「我的游戏」进自己的作品时必须看得到血缘入口,
* 不该因为「从游戏侧进来」被降级。
* ⚠️ 前端不做权限校验:`owner=1` 是前端标记(shell 只在「我的游戏」里写它),手改 URL 也能让页面
* 按共创者侧渲染 —— 它只影响「看不看得到血缘入口」这类**展示降级**,真正的作者数据仍由后端要求
* Bearer + owner 接口把关(未登录/非作者拿不到数据,页面落到 `找不到这款游戏`)。所以
* 「未登录 + owner」在**数据上**不可能同时成立,在**链接上**可以让前端按共创者侧渲染,不构成越权。
*/
export function resolveGameViewerContext(
source: string | null | undefined,
): GameViewerContext {
return isCoCreationSource(source) ? 'co-creator' : 'player';
export function resolveGameViewerContext(input: {
/** URL 上的来源标记(`from`)。 */
source?: string | null;
/** 观看者就是这部作品的作者(`?owner=1`)。 */
isAuthorViewer?: boolean;
}): GameViewerContext {
if (input.isAuthorViewer) return 'co-creator';
return isCoCreationSource(input.source) ? 'co-creator' : 'player';
}
/**
@@ -1253,7 +1253,7 @@ describe('游戏详情返回', () => {
);
await clickPlay();
expect(fromCoCreation).toHaveBeenCalledWith('game-play', {
path: '/games/play?id=game-1&from=co-creation',
path: '/games/play?id=game-1&from=co-creation&backTo=%2Fgames%2Fdetail%3Fid%3Dgame-1%26from%3Dco-creation',
});
first.unmount();
@@ -1269,7 +1269,7 @@ describe('游戏详情返回', () => {
);
await clickPlay();
expect(fromPlayer).toHaveBeenCalledWith('game-play', {
path: '/games/play?id=game-1',
path: '/games/play?id=game-1&backTo=%2Fgames%2Fdetail%3Fid%3Dgame-1',
});
});
@@ -1302,10 +1302,21 @@ describe('游戏详情返回', () => {
).toBe(false);
}
// 来源值 → 一侧
expect(resolveGameViewerContext('co-creation')).toBe('co-creator');
expect(resolveGameViewerContext('CO-CREATION')).toBe('co-creator');
expect(resolveGameViewerContext(null)).toBe('player');
expect(resolveGameViewerContext('game-leaf')).toBe('player');
expect(resolveGameViewerContext({ source: 'co-creation' })).toBe(
'co-creator',
);
expect(resolveGameViewerContext({ source: 'CO-CREATION' })).toBe(
'co-creator',
);
expect(resolveGameViewerContext({ source: null })).toBe('player');
expect(resolveGameViewerContext({ source: 'game-leaf' })).toBe('player');
// 作者本人(?owner=1)一律共创者侧,与入口来源无关(产品拍板)
expect(resolveGameViewerContext({ isAuthorViewer: true })).toBe(
'co-creator',
);
expect(
resolveGameViewerContext({ source: 'co-creation', isAuthorViewer: true }),
).toBe('co-creator');
expect(isCoCreator('player')).toBe(false);
expect(isCoCreator('co-creator')).toBe(true);
});
@@ -1403,7 +1414,7 @@ describe('作品管理 owner 作用域', () => {
fireEvent.click(await screen.findByRole('button', { name: '查看详情' }));
expect(setSelectionStage).toHaveBeenCalledWith('game-detail', {
path: '/games/detail?id=game-1&owner=1',
path: '/games/detail?id=game-1&owner=1&from=co-creation',
});
});
@@ -26,6 +26,7 @@ import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/c
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
import { resolveProfileMudPointDisplayTotal } from '../../../packages/shared/src/utils/mudPoints';
import {
APP_STAGE_ROUTES,
hasAppHistoryBackEntry,
pushAppHistoryPath,
replaceAppHistoryPath,
@@ -48,7 +49,9 @@ import { setCreatorViewer } from '../creator/creatorRelationshipState';
import {
CO_CREATION_DETAIL_SOURCE,
isCoCreator,
PLAY_BACK_TO_QUERY_PARAM,
resolveGameViewerContext,
resolveInternalBackTo,
shouldCarryCoCreationSource,
} from '../game-distribution/gameViewerRole';
import { PaymentCheckoutView } from './PaymentCheckoutView';
@@ -588,9 +591,13 @@ export function PlatformEntryFlowShellImpl({
* 从共创侧(共创 Tab / 族谱页 / 对比页)打开详情时带上 `from=co-creation`,
* 详情页据此决定「看不看得到血缘图与共创标签」;其余来源 = 玩家侧(不带标记)。
* 已经在共创侧详情页里继续跳(血缘链 / 主题 / 相关作品)时保留标记,不中途掉回玩家侧。
* **作者本人**(`owner=1`)也带上标记:产品拍板「作者进自己的作品一律算共创者侧」,
* 而且 rail/dock 高亮读的就是 URL 上的 `from`(同一个谓词),标记到位就自动跟随。
* 用 URL 而不是内存 stage:刷新、深链、分享链接都稳定。
*/
const source = currentCoCreationSource();
const source = options?.owner
? CO_CREATION_DETAIL_SOURCE
: currentCoCreationSource();
if (source) params.set('from', source);
const path = `/games/detail?${params.toString()}`;
setSelectionStage('game-detail', { path });
@@ -599,15 +606,33 @@ export function PlatformEntryFlowShellImpl({
);
/**
* 打开游玩页。**来源标记要一路带过去**:从共创侧详情点「开始游戏 / 立即玩」进游玩页时,
* 游玩页的 rail/dock 高亮仍按共创侧(读 URL 上的 `from`,见 `isCoCreationContext` 那一带);
* 游玩页自己刷新 / 分享 / 再往下跳时同样保持,玩家侧则不带标记。
* 打开游玩页。两件事都要带上:
* 1. **来源标记** `from`:游玩页的 rail/dock 高亮仍按共创侧(读 URL,见 `isCoCreationContext`);
* 2. **返回目标** `backTo`(2026-10-07 用户口径「看他从哪来的」):把**来处**写在 URL 上,
* 游玩页的「返回」据此回到来处(刷新/深链不丢);已经在游玩页里再进游玩页时**保留原来处**。
* 只写站内路径(`APP_STAGE_ROUTES` 里认识的路径),不写绝对 URL。
*/
const openGamePlay = useCallback(
(gameId: string) => {
const params = new URLSearchParams({ id: gameId });
const source = currentCoCreationSource();
if (source) params.set('from', source);
const currentStage = resolveSelectionStageFromPath(
window.location.pathname,
);
const currentLocation = `${window.location.pathname}${window.location.search}`;
const existingBackTo = resolveInternalBackTo(
new URLSearchParams(window.location.search).get(
PLAY_BACK_TO_QUERY_PARAM,
),
);
const backTo =
currentStage === 'game-play'
? existingBackTo
: Object.values(APP_STAGE_ROUTES).includes(window.location.pathname)
? currentLocation
: null;
if (backTo) params.set(PLAY_BACK_TO_QUERY_PARAM, backTo);
const path = `/games/play?${params.toString()}`;
setSelectionStage('game-play', { path });
},
@@ -711,19 +736,40 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
/** 游玩页同理,兜底是它自己的详情页,避免详情↔游玩互相塞历史条目。 */
/**
* 游玩页的「返回」按**来处**回(2026-10-07 用户口径「看他从哪来的」):
* 1. URL 上有合法的 `backTo`(进入游玩页时由 `openGamePlay` 写上)→ **直接回那里**,
* 刷新/深链后同样成立;回详情时用 `resolveSelectionStageFromPath` 反推 stage。
* 2. 没有 `backTo` → 走既有的浏览器原生返回(站内历史)。
* 3. 都没有(直接深链进游玩页)→ 兜底回**它自己的详情页**,没有 id 才回 `/games`。
* 参数只表达「回哪」,不表达「哪一侧」;站外值由 `resolveInternalBackTo` 拦掉(防开放重定向)。
*/
const backFromGamePlay = useCallback(() => {
const params = new URLSearchParams(window.location.search);
const backTo = resolveInternalBackTo(params.get(PLAY_BACK_TO_QUERY_PARAM));
if (backTo) {
const stage = resolveSelectionStageFromPath(
new URL(backTo, window.location.origin).pathname,
);
replaceAppHistoryPath(backTo);
setSelectionStage(stage, { path: backTo });
return;
}
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
const currentGameId = new URLSearchParams(window.location.search).get('id');
const currentGameId = params.get('id');
const source = currentCoCreationSource();
const fallbackParams = new URLSearchParams();
if (currentGameId) fallbackParams.set('id', currentGameId);
if (source) fallbackParams.set('from', source);
const path = currentGameId
? `/games/detail?id=${encodeURIComponent(currentGameId)}`
? `/games/detail?${fallbackParams.toString()}`
: '/games';
replaceAppHistoryPath(path);
setSelectionStage(currentGameId ? 'game-detail' : 'games', { path });
}, [setSelectionStage]);
}, [currentCoCreationSource, setSelectionStage]);
const openEditorProject = useCallback(
(projectId: string, options?: { guide?: boolean; tool?: string }) => {
@@ -871,7 +917,7 @@ export function PlatformEntryFlowShellImpl({
*/
const isCoCreationContext =
isGamesStage &&
(isCoCreator(resolveGameViewerContext(gameEntrySource)) ||
(isCoCreator(resolveGameViewerContext({ source: gameEntrySource })) ||
shouldCarryCoCreationSource({
stage: selectionStage,
currentSource: gameEntrySource,