feat: add recommendation feed scoring

This commit is contained in:
2026-06-07 13:56:17 +08:00
parent c344daba19
commit 9e1549151d
6 changed files with 424 additions and 27 deletions

View File

@@ -175,7 +175,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏``推荐``作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts``usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer但请求选项必须是 local auth impact避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token并只把它作为局部请求头传给运行态客户端不写入全局登录态、不触发 refresh也不把匿名流量伪装成普通用户。当前覆盖矩阵为跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求都必须继续按该身份分流公开读取入口仍可匿名读取创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏``推荐``作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts``usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer但请求选项必须是 local auth impact避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token并只把它作为局部请求头传给运行态客户端不写入全局登录态、不触发 refresh也不把匿名流量伪装成普通用户。当前覆盖矩阵为跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求都必须继续按该身份分流公开读取入口仍可匿名读取创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
## 敲木鱼

View File

@@ -438,6 +438,7 @@ import {
EDUTAINMENT_HIDDEN_MESSAGE,
filterGeneralPublicWorks,
} from './platformEdutainmentVisibility';
import { buildPlatformRecommendedEntries } from './platformRecommendation';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import {
@@ -5408,14 +5409,10 @@ export function PlatformEntryFlowShellImpl({
],
);
const recommendRuntimeEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
filterGeneralPublicWorks([
...featuredGalleryEntries,
...latestGalleryEntries,
]).forEach((entry) => {
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
return buildPlatformRecommendedEntries({
featuredEntries: filterGeneralPublicWorks(featuredGalleryEntries),
latestEntries: filterGeneralPublicWorks(latestGalleryEntries),
});
return Array.from(entryMap.values());
}, [featuredGalleryEntries, latestGalleryEntries]);
const creationHubItems = useMemo<CustomWorldWorkSummary[]>(

View File

@@ -0,0 +1,178 @@
import { describe, expect, test } from 'vitest';
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import { buildPlatformRecommendedEntries } from './platformRecommendation';
const NOW_MS = Date.parse('2026-06-07T12:00:00.000Z');
type PublicCardTestParams = {
id: string;
sourceType?: 'puzzle' | 'match3d' | 'jump-hop';
subtitle?: string;
summaryText?: string;
coverImageSrc?: string | null;
themeTags?: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
publishedAt?: string | null;
updatedAt?: string;
};
function buildPublicCard(
params: PublicCardTestParams,
): PlatformPublicGalleryCard {
const sourceType = params.sourceType ?? 'puzzle';
return {
sourceType,
workId: `${sourceType}-work-${params.id}`,
profileId: `${sourceType}-profile-${params.id}`,
publicWorkCode: `${sourceType.toUpperCase()}-${params.id}`,
ownerUserId: `user-${params.id}`,
authorDisplayName: `${params.id} 作者`,
worldName: `${params.id} 作品`,
subtitle: params.subtitle ?? '公开作品',
summaryText: params.summaryText ?? '公开作品摘要。',
coverImageSrc: params.coverImageSrc ?? `${params.id}.png`,
themeTags: params.themeTags ?? ['推荐'],
playCount: params.playCount ?? 0,
remixCount: params.remixCount ?? 0,
likeCount: params.likeCount ?? 0,
recentPlayCount7d: params.recentPlayCount7d ?? 0,
visibility: 'published',
publishedAt: params.publishedAt ?? '2026-06-01T12:00:00.000Z',
updatedAt:
params.updatedAt ?? params.publishedAt ?? '2026-06-01T12:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
}
describe('buildPlatformRecommendedEntries', () => {
test('combines heat, freshness and featured boost after de-duplicating works', () => {
const coldEntry = buildPublicCard({
id: 'cold',
playCount: 1,
publishedAt: '2026-04-01T12:00:00.000Z',
});
const hotRecentEntry = buildPublicCard({
id: 'hot',
playCount: 8,
likeCount: 4,
recentPlayCount7d: 16,
publishedAt: '2026-06-06T12:00:00.000Z',
});
const curatedEntry = buildPublicCard({
id: 'curated',
playCount: 0,
likeCount: 0,
publishedAt: '2026-05-10T12:00:00.000Z',
});
const entries = buildPlatformRecommendedEntries(
{
featuredEntries: [curatedEntry],
latestEntries: [coldEntry, hotRecentEntry, curatedEntry],
},
{ nowMs: NOW_MS },
);
expect(entries.map((entry) => entry.profileId)).toEqual([
hotRecentEntry.profileId,
curatedEntry.profileId,
coldEntry.profileId,
]);
});
test('interleaves close-score works from different play types', () => {
const firstPuzzle = buildPublicCard({
id: 'puzzle-a',
sourceType: 'puzzle',
likeCount: 2,
});
const secondPuzzle = buildPublicCard({
id: 'puzzle-b',
sourceType: 'puzzle',
likeCount: 2,
});
const match3d = buildPublicCard({
id: 'match3d-a',
sourceType: 'match3d',
likeCount: 2,
});
const entries = buildPlatformRecommendedEntries(
{
featuredEntries: [],
latestEntries: [firstPuzzle, secondPuzzle, match3d],
},
{ nowMs: NOW_MS },
);
expect(entries.map((entry) => entry.profileId)).toEqual([
firstPuzzle.profileId,
match3d.profileId,
secondPuzzle.profileId,
]);
});
test('separates same-type candidates while alternatives remain', () => {
const hotPuzzle = buildPublicCard({
id: 'hot-puzzle',
sourceType: 'puzzle',
recentPlayCount7d: 50,
likeCount: 20,
});
const warmPuzzle = buildPublicCard({
id: 'warm-puzzle',
sourceType: 'puzzle',
recentPlayCount7d: 32,
likeCount: 12,
});
const coldMatch3d = buildPublicCard({
id: 'cold-match3d',
sourceType: 'match3d',
publishedAt: '2026-04-01T12:00:00.000Z',
});
const entries = buildPlatformRecommendedEntries(
{
featuredEntries: [],
latestEntries: [hotPuzzle, warmPuzzle, coldMatch3d],
},
{ nowMs: NOW_MS },
);
expect(entries.map((entry) => entry.profileId)).toEqual([
hotPuzzle.profileId,
coldMatch3d.profileId,
warmPuzzle.profileId,
]);
});
test('falls back to same-type adjacency when no other type remains', () => {
const firstPuzzle = buildPublicCard({
id: 'only-puzzle-a',
sourceType: 'puzzle',
recentPlayCount7d: 8,
});
const secondPuzzle = buildPublicCard({
id: 'only-puzzle-b',
sourceType: 'puzzle',
recentPlayCount7d: 4,
});
const entries = buildPlatformRecommendedEntries(
{
featuredEntries: [],
latestEntries: [firstPuzzle, secondPuzzle],
},
{ nowMs: NOW_MS },
);
expect(entries.map((entry) => entry.profileId)).toEqual([
firstPuzzle.profileId,
secondPuzzle.profileId,
]);
});
});

View File

@@ -0,0 +1,231 @@
import {
buildPlatformPublicGalleryCardKey,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
const MS_PER_DAY = 86_400_000;
const FEATURED_BONUS = 14;
const MAX_FRESHNESS_SCORE = 12;
export type PlatformRecommendationOptions = {
nowMs?: number;
limit?: number;
};
type RecommendationCandidate = {
entry: PlatformPublicGalleryCard;
key: string;
sourceType: string;
firstSeenIndex: number;
isFeatured: boolean;
timestampMs: number;
score: number;
};
type PlatformRecommendationMetricKey =
| 'playCount'
| 'remixCount'
| 'likeCount'
| 'recentPlayCount7d';
function parseRecommendationTimestamp(value: string | null | undefined) {
if (!value) {
return 0;
}
const normalized = value.trim();
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
if (numericTimestamp?.[1]) {
const rawTimestamp = Number(numericTimestamp[1]);
if (Number.isFinite(rawTimestamp)) {
const absoluteTimestamp = Math.abs(rawTimestamp);
if (absoluteTimestamp >= 1_000_000_000_000_000) {
return rawTimestamp / 1000;
}
if (absoluteTimestamp >= 1_000_000_000_000) {
return rawTimestamp;
}
if (absoluteTimestamp >= 1_000_000_000) {
return rawTimestamp * 1000;
}
}
}
const timestamp = new Date(normalized).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
function getRecommendationTimestamp(entry: PlatformPublicGalleryCard) {
return parseRecommendationTimestamp(entry.publishedAt ?? entry.updatedAt);
}
function getRecommendationMetric(
entry: PlatformPublicGalleryCard,
key: PlatformRecommendationMetricKey,
) {
const value = (
entry as Partial<Record<PlatformRecommendationMetricKey, number>>
)[key];
return Math.max(0, Math.round(Number(value ?? 0) || 0));
}
function getRecommendationSourceType(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry) {
if (
entry.sourceType === 'edutainment' &&
'templateId' in entry &&
entry.templateId
) {
return `edutainment:${entry.templateId}`;
}
return entry.sourceType;
}
return 'rpg';
}
function getRecommendationThemeTags(entry: PlatformPublicGalleryCard) {
return 'themeTags' in entry && Array.isArray(entry.themeTags)
? entry.themeTags
: [];
}
function scoreRecommendationCandidate(
candidate: Omit<RecommendationCandidate, 'score'>,
nowMs: number,
) {
const entry = candidate.entry;
const ageDays =
candidate.timestampMs > 0
? Math.max(0, (nowMs - candidate.timestampMs) / MS_PER_DAY)
: Number.POSITIVE_INFINITY;
const freshnessScore = Number.isFinite(ageDays)
? MAX_FRESHNESS_SCORE / (1 + ageDays / 7)
: 0;
const coverScore = entry.coverImageSrc ? 1.5 : 0;
const tagScore = Math.min(3, getRecommendationThemeTags(entry).length) * 0.6;
const summaryScore = entry.summaryText.trim() ? 0.8 : 0;
return (
(candidate.isFeatured ? FEATURED_BONUS : 0) +
Math.log1p(getRecommendationMetric(entry, 'recentPlayCount7d')) * 8 +
Math.log1p(getRecommendationMetric(entry, 'likeCount')) * 5 +
Math.log1p(getRecommendationMetric(entry, 'remixCount')) * 3 +
Math.log1p(getRecommendationMetric(entry, 'playCount')) * 2 +
freshnessScore +
coverScore +
tagScore +
summaryScore
);
}
function compareRecommendationCandidates(
left: RecommendationCandidate,
right: RecommendationCandidate,
) {
const scoreDiff = right.score - left.score;
if (scoreDiff !== 0) {
return scoreDiff;
}
const timeDiff = right.timestampMs - left.timestampMs;
if (timeDiff !== 0) {
return timeDiff;
}
if (left.firstSeenIndex !== right.firstSeenIndex) {
return left.firstSeenIndex - right.firstSeenIndex;
}
return left.key.localeCompare(right.key, 'zh-CN');
}
function diversifyAdjacentSourceTypes(candidates: RecommendationCandidate[]) {
const remaining = [...candidates];
const result: RecommendationCandidate[] = [];
while (remaining.length > 0) {
const lastSourceType = result[result.length - 1]?.sourceType ?? null;
let nextIndex = 0;
if (lastSourceType) {
const alternativeIndex = remaining.findIndex(
(candidate) => candidate.sourceType !== lastSourceType,
);
if (alternativeIndex > 0) {
nextIndex = alternativeIndex;
}
}
const [nextCandidate] = remaining.splice(nextIndex, 1);
if (nextCandidate) {
result.push(nextCandidate);
}
}
return result;
}
export function buildPlatformRecommendedEntries(
params: {
featuredEntries: PlatformPublicGalleryCard[];
latestEntries: PlatformPublicGalleryCard[];
},
options: PlatformRecommendationOptions = {},
) {
const candidateMap = new Map<
string,
Omit<RecommendationCandidate, 'score'>
>();
let firstSeenIndex = 0;
const collectEntries = (
entries: PlatformPublicGalleryCard[],
source: 'featured' | 'latest',
) => {
entries.forEach((entry) => {
const key = buildPlatformPublicGalleryCardKey(entry);
const timestampMs = getRecommendationTimestamp(entry);
const existing = candidateMap.get(key);
if (existing) {
existing.isFeatured = existing.isFeatured || source === 'featured';
if (timestampMs >= existing.timestampMs) {
existing.entry = entry;
existing.timestampMs = timestampMs;
}
return;
}
candidateMap.set(key, {
entry,
key,
sourceType: getRecommendationSourceType(entry),
firstSeenIndex,
isFeatured: source === 'featured',
timestampMs,
});
firstSeenIndex += 1;
});
};
collectEntries(params.featuredEntries, 'featured');
collectEntries(params.latestEntries, 'latest');
const nowMs = options.nowMs ?? Date.now();
const rankedCandidates = Array.from(candidateMap.values())
.map((candidate) => ({
...candidate,
score: scoreRecommendationCandidate(candidate, nowMs),
}))
.sort(compareRecommendationCandidates);
const diversifiedCandidates = diversifyAdjacentSourceTypes(rankedCandidates);
const limit =
typeof options.limit === 'number' && options.limit > 0
? Math.floor(options.limit)
: diversifiedCandidates.length;
return diversifiedCandidates
.slice(0, limit)
.map((candidate) => candidate.entry);
}

View File

@@ -11928,7 +11928,6 @@ test('creation hub gives jump hop wooden fish and bark battle cards the shared d
profileId: 'jump-hop-profile-delete',
ownerUserId: 'user-1',
sourceSessionId: 'jump-hop-session-delete',
themeText: '跳台删除草稿',
workTitle: '跳台删除草稿',
workDescription: '跳一跳草稿也应接入统一删除。',
themeText: '跳台',

View File

@@ -78,7 +78,6 @@ import type {
WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import { refreshStoredAccessToken } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService';
import {
@@ -133,6 +132,7 @@ import {
isEdutainmentEntryEnabled,
} from '../platform-entry/platformEdutainmentVisibility';
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import { buildPlatformRecommendedEntries } from '../platform-entry/platformRecommendation';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
@@ -154,7 +154,6 @@ import {
isWoodenFishGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformPublicWorkCode,
resolvePlatformWorkAuthorDisplayName,
resolvePlatformWorldCoverImage,
resolvePlatformWorldCoverSlides,
@@ -5379,15 +5378,16 @@ export function RpgEntryHomeView({
const desktopHeroStripEntries = (
featuredShelf.length > 0 ? featuredShelf : generalLatestEntries
).slice(0, 5);
const recommendedFeedEntries = useMemo(
() =>
buildPlatformRecommendedEntries({
featuredEntries: featuredShelf,
latestEntries: generalLatestEntries,
}),
[featuredShelf, generalLatestEntries],
);
// 网页端保留原有宽屏布局,只把模块数据同步到移动端首页频道语义。
const desktopRecommendEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredShelf, ...generalLatestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, generalLatestEntries]);
const desktopRecommendEntries = recommendedFeedEntries;
const desktopTodayEntries = useMemo(
() => filterTodayPublishedEntries(generalLatestEntries),
[generalLatestEntries],
@@ -5395,14 +5395,6 @@ export function RpgEntryHomeView({
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2);
const recommendedFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredShelf, ...generalLatestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, generalLatestEntries]);
const discoverFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
const sourceEntries =