merge latest master
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m54s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 4m55s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m28s
Project CI / Backend tests (pull_request) Successful in 6m26s
Project CI / Frontend tests (pull_request) Successful in 2m51s
Project CI / Native shell tests (pull_request) Successful in 7m1s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m14s
Project CI / Repository checks (pull_request) Successful in 6m27s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m54s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 4m55s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m28s
Project CI / Backend tests (pull_request) Successful in 6m26s
Project CI / Frontend tests (pull_request) Successful in 2m51s
Project CI / Native shell tests (pull_request) Successful in 7m1s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m14s
Project CI / Repository checks (pull_request) Successful in 6m27s
This commit is contained in:
@@ -219,6 +219,7 @@ module.exports = {
|
||||
'!src/services/frontendRuntimeConfigService.test.ts',
|
||||
'!src/services/gameDistributionClient.ts',
|
||||
'!src/services/gameDistributionClient.test.ts',
|
||||
'!src/services/gamePlayClientId.ts',
|
||||
'!src/services/sseStream.ts',
|
||||
'!src/services/sseStream.test.ts',
|
||||
'src/AdventurePanel.tsx',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AdminSessionPayload } from '../api/adminApiTypes';
|
||||
import { adminRoutes } from './adminRoutes';
|
||||
import { AdminShell } from './AdminShell';
|
||||
|
||||
const admin: AdminSessionPayload = {
|
||||
subject: 'admin-1',
|
||||
username: 'owner',
|
||||
displayName: '后台管理员',
|
||||
roles: ['owner'],
|
||||
accountRole: 'owner',
|
||||
tabPermissions: [],
|
||||
actionPermissions: [],
|
||||
issuedAt: '2026-10-04T00:00:00.000Z',
|
||||
expiresAt: '2026-10-05T00:00:00.000Z',
|
||||
};
|
||||
|
||||
test('侧边栏默认展开当前页分组并支持折叠其它分组', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRouteChange = vi.fn();
|
||||
const view = render(
|
||||
<AdminShell
|
||||
admin={admin}
|
||||
routeId="dashboard"
|
||||
routes={adminRoutes}
|
||||
onLogout={vi.fn()}
|
||||
onRouteChange={onRouteChange}
|
||||
>
|
||||
<div>页面内容</div>
|
||||
</AdminShell>,
|
||||
);
|
||||
|
||||
const sidebar = screen.getAllByRole('navigation', { name: '后台导航' })[0]!;
|
||||
const workspaceToggle = within(sidebar).getByRole('button', {
|
||||
name: /工作台/,
|
||||
});
|
||||
const observabilityToggle = within(sidebar).getByRole('button', {
|
||||
name: /数据与监控/,
|
||||
});
|
||||
|
||||
expect(workspaceToggle.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(
|
||||
within(sidebar).getByRole('button', { name: '服务总览' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(sidebar).queryByRole('button', { name: '埋点数据' }),
|
||||
).toBeNull();
|
||||
|
||||
await user.click(observabilityToggle);
|
||||
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('true');
|
||||
await user.click(within(sidebar).getByRole('button', { name: '埋点数据' }));
|
||||
expect(onRouteChange).toHaveBeenCalledWith('tracking');
|
||||
|
||||
await user.click(observabilityToggle);
|
||||
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('false');
|
||||
view.rerender(
|
||||
<AdminShell
|
||||
admin={admin}
|
||||
routeId="tracking"
|
||||
routes={adminRoutes}
|
||||
onLogout={vi.fn()}
|
||||
onRouteChange={onRouteChange}
|
||||
>
|
||||
<div>页面内容</div>
|
||||
</AdminShell>,
|
||||
);
|
||||
expect(
|
||||
within(screen.getAllByRole('navigation', { name: '后台导航' })[0]!)
|
||||
.getByRole('button', { name: /数据与监控/ })
|
||||
.getAttribute('aria-expanded'),
|
||||
).toBe('true');
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Activity,
|
||||
BadgeDollarSign,
|
||||
Bug,
|
||||
ChevronDown,
|
||||
Coins,
|
||||
Database,
|
||||
FolderArchive,
|
||||
@@ -22,10 +23,15 @@ import {
|
||||
Users,
|
||||
WalletCards,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import type { AdminSessionPayload } from '../api/adminApiTypes';
|
||||
import type { AdminRouteDefinition, AdminRouteId } from './adminRoutes';
|
||||
import {
|
||||
type AdminNavGroupId,
|
||||
type AdminRouteDefinition,
|
||||
type AdminRouteId,
|
||||
getAdminNavGroups,
|
||||
} from './adminRoutes';
|
||||
|
||||
interface AdminShellProps {
|
||||
admin: AdminSessionPayload;
|
||||
@@ -72,6 +78,67 @@ export function AdminShell({
|
||||
onRouteChange,
|
||||
onLogout,
|
||||
}: AdminShellProps) {
|
||||
const navGroups = getAdminNavGroups(routes);
|
||||
const activeGroupId =
|
||||
navGroups.find((group) =>
|
||||
group.routes.some((route) => route.id === routeId),
|
||||
)?.id ?? navGroups[0]?.id;
|
||||
const [collapsedGroupIds, setCollapsedGroupIds] = useState<
|
||||
Set<AdminNavGroupId>
|
||||
>(() => {
|
||||
return new Set(
|
||||
navGroups
|
||||
.map((group) => group.id)
|
||||
.filter((groupId) => groupId !== activeGroupId),
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeGroupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCollapsedGroupIds((current) => {
|
||||
if (!current.has(activeGroupId)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next = new Set(current);
|
||||
next.delete(activeGroupId);
|
||||
return next;
|
||||
});
|
||||
}, [activeGroupId, routeId]);
|
||||
|
||||
const toggleGroup = (groupId: AdminNavGroupId) => {
|
||||
setCollapsedGroupIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderRouteButton = (route: AdminRouteDefinition) => {
|
||||
const Icon = routeIcons[route.id];
|
||||
return (
|
||||
<button
|
||||
className="admin-nav-button"
|
||||
aria-current={route.id === routeId ? 'page' : undefined}
|
||||
data-active={route.id === routeId}
|
||||
key={route.id}
|
||||
title={route.label}
|
||||
type="button"
|
||||
onClick={() => onRouteChange(route.id)}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
<span>{route.label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<aside className="admin-sidebar">
|
||||
@@ -86,20 +153,35 @@ export function AdminShell({
|
||||
</div>
|
||||
|
||||
<nav className="admin-nav" aria-label="后台导航">
|
||||
{routes.map((route) => {
|
||||
const Icon = routeIcons[route.id];
|
||||
{navGroups.map((group) => {
|
||||
const isCollapsed = collapsedGroupIds.has(group.id);
|
||||
const groupItemsId = `admin-nav-group-${group.id}`;
|
||||
return (
|
||||
<button
|
||||
className="admin-nav-button"
|
||||
data-active={route.id === routeId}
|
||||
key={route.id}
|
||||
title={route.label}
|
||||
type="button"
|
||||
onClick={() => onRouteChange(route.id)}
|
||||
<div
|
||||
className="admin-nav-group"
|
||||
data-collapsed={isCollapsed}
|
||||
key={group.id}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
<span>{route.label}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-controls={groupItemsId}
|
||||
aria-expanded={!isCollapsed}
|
||||
className="admin-nav-group-toggle"
|
||||
type="button"
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
<ChevronDown size={15} aria-hidden="true" />
|
||||
<span>{group.label}</span>
|
||||
<small aria-hidden="true">{group.routes.length}</small>
|
||||
</button>
|
||||
<div
|
||||
className="admin-nav-group-items"
|
||||
data-collapsed={isCollapsed}
|
||||
hidden={isCollapsed}
|
||||
id={groupItemsId}
|
||||
>
|
||||
{group.routes.map(renderRouteButton)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -3,11 +3,46 @@ import { expect, test } from 'vitest';
|
||||
import {
|
||||
adminRoutes,
|
||||
getAccessibleAdminRoutes,
|
||||
getAdminNavGroups,
|
||||
resolveAccessibleAdminRoute,
|
||||
resolveAdminRoute,
|
||||
routeHash,
|
||||
} from './adminRoutes';
|
||||
|
||||
test('后台侧边栏路由按业务分组且不丢失任何页签', () => {
|
||||
const groups = getAdminNavGroups(adminRoutes);
|
||||
const groupedRouteIds = groups.flatMap((group) =>
|
||||
group.routes.map((route) => route.id),
|
||||
);
|
||||
|
||||
expect(groups.map((group) => group.label)).toEqual([
|
||||
'工作台',
|
||||
'数据与监控',
|
||||
'运营配置',
|
||||
'充值与支付',
|
||||
'AGC 管理',
|
||||
'内容运营',
|
||||
'账号权限',
|
||||
]);
|
||||
expect(groupedRouteIds).toEqual(adminRoutes.map((route) => route.id));
|
||||
});
|
||||
|
||||
test('后台侧边栏分组只保留当前管理员可见的页签', () => {
|
||||
const groups = getAdminNavGroups(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['tracking', 'game-reviews', 'accounts'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
groups.map((group) => [group.label, group.routes.map((route) => route.id)]),
|
||||
).toEqual([
|
||||
['数据与监控', ['tracking']],
|
||||
['内容运营', ['game-reviews']],
|
||||
]);
|
||||
});
|
||||
|
||||
test('游戏评价路由独立授权并支持游戏参数', () => {
|
||||
expect(resolveAdminRoute('#game-reviews?gameId=game_1')).toBe('game-reviews');
|
||||
expect(
|
||||
|
||||
@@ -71,6 +71,91 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* 桌面端侧边栏的导航分组。分组只影响展示顺序和折叠状态,不改变路由
|
||||
* 权限、hash 或移动端底栏的平铺顺序。
|
||||
*/
|
||||
export type AdminNavGroupId =
|
||||
| 'workspace'
|
||||
| 'observability'
|
||||
| 'operations'
|
||||
| 'billing'
|
||||
| 'content'
|
||||
| 'agc'
|
||||
| 'access';
|
||||
|
||||
export interface AdminNavGroupDefinition {
|
||||
id: AdminNavGroupId;
|
||||
label: string;
|
||||
routeIds: AdminRouteId[];
|
||||
}
|
||||
|
||||
export const adminNavGroups: AdminNavGroupDefinition[] = [
|
||||
{
|
||||
id: 'workspace',
|
||||
label: '工作台',
|
||||
routeIds: ['dashboard', 'overview', 'tables', 'debug'],
|
||||
},
|
||||
{
|
||||
id: 'observability',
|
||||
label: '数据与监控',
|
||||
routeIds: ['tracking', 'agc-tracking', 'error-reports'],
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
label: '运营配置',
|
||||
routeIds: ['gray-release', 'redeem', 'invite', 'profile-wallet', 'tasks'],
|
||||
},
|
||||
{
|
||||
id: 'billing',
|
||||
label: '充值与支付',
|
||||
routeIds: ['recharge-products', 'recharge-orders', 'payment-orders'],
|
||||
},
|
||||
{
|
||||
id: 'agc',
|
||||
label: 'AGC 管理',
|
||||
routeIds: ['editor-generation-pricing', 'agc-models', 'agc-templates'],
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
label: '内容运营',
|
||||
routeIds: [
|
||||
'editor-showcase',
|
||||
'game-distribution',
|
||||
'game-management',
|
||||
'game-reviews',
|
||||
'editor-assets',
|
||||
'project-snapshots',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'access',
|
||||
label: '账号权限',
|
||||
routeIds: ['accounts'],
|
||||
},
|
||||
];
|
||||
|
||||
export interface AdminNavGroup {
|
||||
id: AdminNavGroupId;
|
||||
label: string;
|
||||
routes: AdminRouteDefinition[];
|
||||
}
|
||||
|
||||
/** 按固定业务分组筛选当前管理员可见的路由。 */
|
||||
export function getAdminNavGroups(
|
||||
routes: AdminRouteDefinition[],
|
||||
): AdminNavGroup[] {
|
||||
return adminNavGroups
|
||||
.map((group) => ({
|
||||
id: group.id,
|
||||
label: group.label,
|
||||
routes: group.routeIds
|
||||
.map((routeId) => routes.find((route) => route.id === routeId))
|
||||
.filter((route): route is AdminRouteDefinition => route !== undefined),
|
||||
}))
|
||||
.filter((group) => group.routes.length > 0);
|
||||
}
|
||||
|
||||
export interface AdminRouteAccess {
|
||||
accountRole: 'owner' | 'member';
|
||||
tabPermissions: string[];
|
||||
|
||||
@@ -289,7 +289,66 @@ button:disabled {
|
||||
|
||||
.admin-nav {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-nav-group {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-nav-group-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 0 8px;
|
||||
color: #a08472;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-nav-group-toggle svg {
|
||||
flex: 0 0 auto;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.admin-nav-group[data-collapsed='true'] .admin-nav-group-toggle svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.admin-nav-group-toggle small {
|
||||
min-width: 18px;
|
||||
margin-left: auto;
|
||||
border-radius: 999px;
|
||||
padding: 2px 5px;
|
||||
color: #b18d76;
|
||||
background: #faf0e8;
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-nav-group-toggle:hover,
|
||||
.admin-nav-group-toggle:focus-visible {
|
||||
color: #8f3f27;
|
||||
background: #fff8f2;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-nav-group-items {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-nav-group-items[data-collapsed='true'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-nav-button,
|
||||
@@ -305,8 +364,8 @@ button:disabled {
|
||||
.admin-nav-button {
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
min-height: 40px;
|
||||
padding: 0 12px 0 28px;
|
||||
color: #755a49;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,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):已实现并通过本地定向验证,待用户验收,未部署;公开列表/详情携带真实摘要,卡片显示一位小数均分与人数,复用有效评价统计。
|
||||
- [游戏游玩次数计数](./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、参数、幂等和兼容合同。
|
||||
- [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# 【ADR】游戏游玩次数计数
|
||||
|
||||
状态:已接受(2026-10-03)
|
||||
|
||||
## 背景
|
||||
|
||||
网站游戏分发已经展示「X 次游玩」:目录卡片、详情、作者「我的游戏」与后台「游戏管理」都读
|
||||
`game_distribution_game.play_count`,契约里也有 `playCount`。但这条链路只读不写——游戏创建时置 `0`,
|
||||
之后没有任何自增、reducer 或 procedure;因此所有游戏恒显示 `0` 次游玩。
|
||||
|
||||
主规范要求「不虚构评分、玩家数、排名和收藏状态」,里程碑与证据矩阵以「新游戏显示 0、目录数据来自真实
|
||||
接口」作为无假统计的证据,却从未定义「一次游玩如何累加」。相关现状:
|
||||
|
||||
- **触发信号不存在**:游玩页 `/games/play` 先给封面与「开始游戏」,点击后才挂
|
||||
`sandbox="allow-scripts"` 的 iframe;`startGame()` 纯本地状态,不发任何请求。AGC 客户端 12 类埋点白名单
|
||||
不含「开始游玩」,主站路由埋点只有查看统计。
|
||||
- **发行网关不能承担计数**:`serve_release_entry` / `serve_release_asset` 只按 `gameId` 服务当前公开版本
|
||||
文件;一次加载会打多个资源请求,且按设计禁带平台 Cookie、拿不到会话身份。
|
||||
- **平台没有匿名身份**:没有匿名访客 cookie,web 端没有 device/client id;`x-client-instance-id` 只在登录
|
||||
链路采集。
|
||||
- **没有现成的服务端缓冲管线可复用**:AGC 埋点管线是客户端本地 15 分钟一批、服务端同步原子入库且明确
|
||||
不做聚合;真正的「内存缓冲 + 周期 flush + 关停 flush」范式是主站路由埋点 `tracking_outbox`。
|
||||
- **部署与限流现状**:api-server 单实例;无 CSRF/Origin 中间件;应用层只有并发背压,按 IP 令牌桶在独立
|
||||
的 `pingora-gateway`;现有缓存惯例是 std `OnceLock`/`LazyLock` + `Mutex`(无 `dashmap`/`moka`)。
|
||||
|
||||
## 决策
|
||||
|
||||
### 1. 触发点 = 游玩页点「开始游戏」
|
||||
|
||||
以用户点击「开始游戏」后的前端上报作为一次游玩。理由:这是产品定义的启动动作,游客可用;`iframe load`
|
||||
只代表文档加载(规范已明确不能当业务状态),发行网关无法区分会话且会因资源请求重复。点击后即使 iframe
|
||||
超时/未真正载入也计一次(用户意图)。
|
||||
|
||||
### 2. 落点 = 复用现有 `play_count`
|
||||
|
||||
`game_distribution_game.play_count` 已经是 `u64`、已进公开/后台 DTO、已进四处 UI。只新增写入路径,不改
|
||||
字段语义、不新建计数表,避免双源。计数跟随游戏身份,不随发行版本。
|
||||
|
||||
### 3. 范围 = 只做累计总次数
|
||||
|
||||
不做日粒度 / 近 7 天热度、独立玩家数、榜单、推荐。`public_work_play_daily_stat` 属已退役自定义世界口径,
|
||||
不复活。
|
||||
|
||||
### 4. api-server 纯内存缓冲 + 周期 flush
|
||||
|
||||
- **纯内存,不落盘**:崩溃、被杀和正常 SIGTERM/滚动重启都允许丢最后一个 flush 窗口;进程退出不做
|
||||
force flush,关停路径不为计数等待网络(2026-10-04 修订,见「修订记录」)。
|
||||
- **两张表**:增量表 `pending: HashMap<gameId, u64>`(5 秒级、flush 即清)与 30 分钟去重窗口表 `seen:
|
||||
HashMap<identity + gameId, timestamp>`(30 分钟级、按 TTL 清理)。两者键不同、生命周期差 360 倍,不能合并:
|
||||
合并会把 30 分钟窗口状态塞进 5 秒清空的表,或让 flush 需要按 gameId 重新聚合。
|
||||
- **限流表**:`rate: HashMap<ip + gameId, (windowStart, count)>` 固定窗口。
|
||||
- **并发**:请求路径只在短锁内做 HashMap 命中 + 自增;**flush 的网络调用移出锁外**。
|
||||
|
||||
### 5. 写入形状 = 批量 procedure
|
||||
|
||||
一次 flush 发一个 procedure,入参 `Vec<{gameId, delta}>`(按 500 分块),事务内逐条
|
||||
`play_count = play_count.saturating_add(delta)`;procedure 内**原子校验**游戏当前为 `published` 且
|
||||
`active_version_id` 存在,非公开跳过。**不 bump `updated_at`**:它只表示公开资料变更,且作者自有列表按它
|
||||
排序,bump 会让每次游玩重排作者列表。
|
||||
|
||||
### 6. 失败语义 = 少计优于双计
|
||||
|
||||
只重试确定未发出的 `Build`;`Timeout` / `ConnectDropped` 无法判断是否已提交,直接丢弃该批并 `warn!` 记录
|
||||
丢量。理由是:超时后重试会在"其实已提交"时造成系统性双计,而丢弃只是偶发少计——对一个非交易展示指标,
|
||||
后者更可接受(perf 优先于 correctness)。
|
||||
|
||||
一次 flush 按 500 分块;任一分片失败即终止本次 flush 的后续分片,剩余增量直接丢弃(`Build` 只把当前分片
|
||||
放回)。连接不通时剩余分片只会重复同样的失败,逐个重试会把 worker 卡在多次连接超时上(2026-10-04 补充)。
|
||||
|
||||
### 7. 接口
|
||||
|
||||
`POST /api/game-distribution/games/{game_id}/plays`,公开端点(可选 bearer):
|
||||
|
||||
- 不挂 `require_bearer_auth`;用 `optional_access_token_from_headers` 拿可选 `userId`。
|
||||
- **不加 `Idempotency-Key`**(与其它游戏分发写路由惯例不同):高频计数用不上幂等收据,30 分钟去重窗口就是
|
||||
护栏。
|
||||
- 非公开 / 下架 / 封禁返回 `404` 且不计数;被限流返回 `429`;成功统一 `200 {recorded: bool}`。
|
||||
- 前端 fire-and-forget,**任何失败静默、绝不阻断游玩**;不做发行网关兜底计数。
|
||||
|
||||
### 8. 身份与去重键
|
||||
|
||||
登录用 `userId`;匿名用前端 `localStorage` 持久随机 `clientId`(随请求体带上);两者都缺失时回退
|
||||
`IP + UA`。30 分钟窗口按 `identity + gameId`。不新造匿名 cookie、不依赖登录后才有的设备指纹。`IP`
|
||||
取 nginx 覆盖写入的 `X-Real-IP`(无 CDN 时即真实 TCP 对端),不取可伪造的 `X-Forwarded-For` 首段;
|
||||
限流键与微信支付下单的 `payer_client_ip` 同源(2026-10-04 补充)。
|
||||
|
||||
### 9. 落位 = 纯持久化 / 读模型
|
||||
|
||||
procedure 在 `spacetime-module`,facade + mapper 在 `spacetime-client`,缓冲 / worker / 端点在
|
||||
`api-server`;`module-game-distribution` 不动(自增不是领域规则,与既有「点赞计数」直接落持久化流程同构)。
|
||||
新增 procedure 不改表,schema guard 不触发,但必须 `npm run spacetime:generate` 重生成绑定并同步文档。
|
||||
|
||||
### 10. 展示一致性 = 接受滞后
|
||||
|
||||
读路径(目录 / 详情)继续只读 DB,不叠加内存 pending;接受「flush 间隔 + 写库」的 ≤10 秒滞后。
|
||||
|
||||
## 影响与代价
|
||||
|
||||
- 计数非实时,最多一个 flush 窗口的滞后;崩溃 / kill 丢最后一个窗口;模糊传输错误少计。
|
||||
- 匿名 `clientId` 可被清除 / 伪造,指标定位为展示用次数,仅靠限流兜底。
|
||||
- 单实例前提:去重 / 限流窗口不跨实例;将来多实例时各实例自行 flush(加法幂等),窗口不共享。
|
||||
- 不 bump `updated_at`,作者自有列表排序、公开修订 CAS 均不受影响。
|
||||
- 新增 procedure 只改 ABI(绑定),不改表 schema / `migration.rs`。
|
||||
|
||||
## 备选方案与取舍
|
||||
|
||||
1. **发行网关服务端计数**:一次加载多资源请求会重复、禁 Cookie 拿不到会话、无法去重。已否决。
|
||||
2. **复用 AGC 客户端埋点管线**:服务端同步入库且明确不做聚合,客户端 15 分钟批次,白名单不含游玩。已否决。
|
||||
3. **落盘 / outbox 保可靠**:无持久性需求(崩溃丢窗口已接受),引入磁盘与独立目录要求。已否决。
|
||||
4. **单张 map / 不做去重**:会重复计数;去重状态与增量生命周期不同。已否决。
|
||||
5. **每个游戏一次 procedure**:N 次 WebSocket 往返。已否决。
|
||||
6. **模糊失败重试**:超时已提交时系统性双计。已否决。
|
||||
7. **匿名 HttpOnly cookie / 纯 IP 去重**:前者要新造 cookie 与跨端 / 沙箱处理,后者在 NAT 下把多人并成一人。
|
||||
已否决。
|
||||
|
||||
## 明确不做
|
||||
|
||||
- 日粒度 / 近 7 天 / 独立玩家数 / 榜单 / 推荐。
|
||||
- AGC 客户端界面埋点、外部 API / External OpenAPI 扩展。
|
||||
- 服务端网关兜底计数、CSRF token、应用层全局按 IP 限流(沿用既有限流与网关能力)。
|
||||
- 幂等收据表、双计补偿、跨实例窗口共享。
|
||||
|
||||
## 落地与验收
|
||||
|
||||
- 实施边界:`spacetime-module` 新增批量自增 procedure 与 `SpacetimeType`;`spacetime-client` facade +
|
||||
mapper;`api-server` 新增计数模块(增量 / 去重 / 限流 / flush worker)、AppState 接线与公开
|
||||
端点;前端 `gameDistributionClient` 增 `recordGamePlay` 并在 `startGame()` 触发。
|
||||
- 权威文档同步:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`(路由表与游戏分发合同节)、
|
||||
`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`(`game_distribution_game` 的 procedure 与
|
||||
写入口径)。
|
||||
- 验收判据:新游戏 `0` → 游客点一次 ≤10s 内显示 `1`;同身份 30 分钟内重复点击不增、不同身份各 `+1`;
|
||||
超限流 `429` 且不写;下架 `404` 不计数但历史值保留;SIGTERM 关停允许丢最后一个未落库窗口;计数接口任何失败都
|
||||
不影响游玩页;相关 Rust / 前端定向测试与 schema / DDD / 绑定 / DTO parity / 编码 / doc-index 门禁全绿。
|
||||
|
||||
## 修订记录
|
||||
|
||||
- 2026-10-03:初版。
|
||||
- 2026-10-04:关停不再强制 flush(原「正常 SIGTERM/滚动重启必须在 `finalize_shutdown` 内 force flush」
|
||||
作废)。理由:关停时最后一个窗口丢失概率极低,而强制 flush 需要把 worker 生命周期接进关停顺序并为在途
|
||||
网络写入等待;按"perf 与简单优先"取舍,直接放弃该窗口。同日明确 flush 任一分片失败即丢弃剩余分片。
|
||||
- 2026-10-04:客户端 IP 解析改为优先 nginx 覆盖写入的 `X-Real-IP`,`X-Forwarded-For` 只作回退且取最后
|
||||
一段(nginx 用 `$proxy_add_x_forwarded_for` 追加的真实对端),不再信任可伪造的首段——公开上报端点原来
|
||||
用它做匿名身份与限流键,可被伪造 IP 绕过并灌水。无 CDN 前置时 `X-Real-IP` 即真实客户端。
|
||||
@@ -1,5 +1,24 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-10-04 游戏游玩次数修订:关停不强制 flush、flush 失败丢弃剩余分片、客户端 IP 只信 X-Real-IP
|
||||
|
||||
- 变更:ADR `docs/adr/【ADR】游戏游玩次数计数-2026-10-03.md` 修订——原「正常 SIGTERM/滚动重启必须在 `finalize_shutdown` 内 force flush」作废;崩溃、被杀、正常关停都允许丢最后一个未落库窗口,`api-server` 不再注册关停 flush。
|
||||
- 新增:一次 flush 按 500 分片,任一分片失败即终止本次 flush,剩余分片直接丢弃(`Build` 只把当前分片放回下一轮),避免连接不通时每个分片各等一次连接超时把 worker 卡住。
|
||||
- 理由:关停丢一个窗口概率极低,强制 flush 要为在途网络写入等待、并把 worker 生命周期接进关停顺序;按 perf 与简单优先取舍。
|
||||
- 受影响实现:`game_play_counter_worker.rs`(删 `flush_game_play_counter_for_shutdown`、失败即 break)、`main.rs`(`finalize_shutdown` 去掉计数 flush)、`game_play_counter.rs`(`take_pending` 仅测试使用)、`modules/game_distribution.rs`(上报先做内存限流预检再查公开可见性)。
|
||||
- 安全修正:`request_context::client_ip_from_headers` 改为优先 nginx 覆盖写入的 `X-Real-IP`,`X-Forwarded-For` 只作回退且取最后一段(nginx 用 `$proxy_add_x_forwarded_for` 追加的真实对端),不再信任可伪造的首段;公开上报端点的匿名身份/限流键与微信支付下单的 `payer_client_ip` 同时受益。无 CDN 前置时 `X-Real-IP` 即真实客户端。
|
||||
|
||||
## 2026-10-03 游戏游玩次数:api-server 内存去重缓冲 + 批量 procedure 落 play_count
|
||||
|
||||
- 背景:`game_distribution_game.play_count` 早已存在且随公开投影展示,但没有任何写入口;浏览列表、详情或发行网关加载都不能算「游玩」。需要一个不拖慢进入游戏、崩溃时最多少计一个窗口的上报链路。完整决策与备选方案见 ADR `docs/adr/【ADR】游戏游玩次数计数-2026-10-03.md`。
|
||||
- 触发与落点:游玩页点击「开始游戏」时网页 fire-and-forget 上报 `POST /api/game-distribution/games/{gameId}/plays`;不建新表,累加既有 `play_count`。
|
||||
- 缓冲与写入:`api-server` 纯内存聚合,`GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS`(默认 5s)到点批量调用新 procedure `increment_game_distribution_game_play_counts_and_return`(输入 `Vec<{gameId, delta}>`);事务内只对 `published` 且有有效 `active_version_id` 的记录 `saturating_add`,且不更新 `updated_at`(避免重排作者列表)。读路径不叠加内存值,展示最多滞后一个 flush 间隔。~~正常关停强制 flush~~(2026-10-04 修订:关停不再强制 flush,见上条)。
|
||||
- 身份与限流:登录用 `userId`、匿名用网页 `localStorage` 的 `clientId`(不可用时退化为会话内存值)、都拿不到回退 `IP + UA`;`identity + gameId` 30 分钟去重,`IP + gameId` 每分钟 60 次固定窗口限流。非公开/下架/封禁返回 404 且不计数;无效 Bearer 按匿名处理,绝不让计数阻断游玩。
|
||||
- 失败语义:只把 `SpacetimeClientError::Build`(未发出)放回重试;`Timeout` / `ConnectDropped` / `Procedure` 直接丢弃并记录丢失量——少计优于双计,本指标不做双计补偿,也不共享跨实例去重窗口。一次 flush 按 500 分片,任一分片失败即终止本次 flush,剩余分片直接丢弃(2026-10-04 补充)。
|
||||
- 影响范围:`spacetime-module/game_distribution.rs`(输入类型 + procedure + tx)、`spacetime-client` facade 与生成绑定、`api-server` 新增 `game_play_counter.rs` / `game_play_counter_worker.rs` 及 config/state/main/handler、前端 `gamePlayClientId.ts` / `gameDistributionClient.ts` / `GamePlayPage.tsx`、`.eslintrc.cjs` 白名单。
|
||||
- 权威文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 的 `game_distribution_game` 节,以及 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` 的「游玩计数(已实现)」节。
|
||||
- 验证:`cargo check -p api-server` 与 `cargo test -p api-server game_play_counter`(9 passed)通过;前端定向 vitest(点击上报断言 + clientId 稳定性)与 `eslint --max-warnings 0` 通过;`npm run check:server-rs-ddd`、`npm run check:generated-bindings`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。
|
||||
|
||||
## 2026-10-03 AGC 画布引用统一走「活跃聊天输入区」注册表(Issue 602)
|
||||
|
||||
- 背景:画布的「引用」按钮与「拖拽批量引用」只派发 window 事件,消费者只有 `App.tsx` 一处,而它插的是绑在 `PlanningChatView` 上的 `chatComposerRef`;2026-09-22 DirectProject 拆分后普通项目走 `directProjectMode` 提前 return,渲染不到策划面 → ref 恒为 `null`,可选链静默吞掉点击(画布上是死按钮)。同一批合并冲突还丢了 `RESOURCE_REFERENCE_INSERT_MANY_EVENT` 的监听,批量引用连消费者都没有。
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
## 范围
|
||||
|
||||
- 后台管理默认入口为 `#dashboard`,原服务 / 数据库状态页保留为 `#overview`,导航展示名为“服务总览”。
|
||||
- 桌面端后台侧边栏按“工作台 / 数据与监控 / 运营配置 / 充值与支付 / AGC 管理 / 内容运营 / 账号权限”分组,默认只展开当前路由所在组。分组标题可独立折叠;每次切换路由(包括同组内跳转和浏览器 hash 导航)自动展开目标组,折叠动作不切换页面。只展示当前账号有权访问的页签,空组不渲染,组内数量只统计可访问页签。折叠状态仅保留在当前页面会话;分组不改变路由 hash、页签权限或移动端底栏的平铺顺序。
|
||||
- Dashboard 由 `GET /admin/api/dashboard` 提供统一 BFF 投影,前端只展示后端返回的 `range`、`metrics`、`charts`、`operations` 和 `warnings`。
|
||||
- 不新增持久化统计表或字段;profile / tracking 私有事实由仅 runtime service identity 可调用的 `get_admin_dashboard_stats_and_return` procedure 在同一事务快照内聚合,再经 `spacetime-client` facade 返回 api-server;素材与钱包继续复用原有查询。api-server 不再通过固定 `LIMIT` 拉取原始访问明细后自行拼装精确指标;该权威聚合失败时整个 Dashboard 请求失败,不用 0 伪装未知值。
|
||||
- 当前 profile / tracking 表没有覆盖这些跨 scope、跨日期统计的现成索引,因此 procedure 为保证精确性仍需遍历相关事实;上线后需监控调用耗时,数据规模继续增长时再以日期前缀索引或持久化日聚合事实替换,不能重新引入固定行数截断。
|
||||
|
||||
@@ -485,6 +485,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
- 公开素材:游戏行末尾追加可空 `cover_object_key` 与 `screenshots_json`(截图 `{assetId, objectKey}` 数组);创建游戏时 `api-server` 就复核封面/截图素材存在且属于当前作者(不存在 400、他人素材 403),创建版本时按同一口径再次复核并派生对象键。 发布写入受灰度配置键 `game-distribution:publish` 约束:**灰度默认关闭**,未配置或 `enabled=false` 时写入口(创建游戏/版本、确认包、送审、审核通过激活)返回 503 `GAME_DISTRIBUTION_PUBLISH_DISABLED`,`enabled=true` 且白名单/比例/标签命中才放行,读取与安全下架保持可用;同一判据在 `GET /api/runtime/frontend-config` 以 `gameDistributionPublishEnabled` 下发给前端入口,匿名恒为 `false`。只有可见性为 `published` 且存在有效 `active_version_id` 的游戏,其封面/截图素材才在 `/api/assets/read-url` 上获得匿名读授权。
|
||||
- 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。
|
||||
- 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published` 且存在有效 `active_version_id` 的投影。
|
||||
- 游玩计数写入:`play_count` 只由批量 procedure `increment_game_distribution_game_play_counts_and_return`(输入 `GameDistributionPlayCountIncrementInput { increments: Vec<{ gameId, delta }> }`)累加。`api-server` 在内存里按 `identity + gameId` 做 30 分钟去重、按 `IP + gameId` 做固定窗口限流后,按 `GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS`(默认 5 秒)批量落库;事务内只对 `published` 且存在有效 `active_version_id` 的游戏 `saturating_add`,非公开静默跳过,且**不更新** `updated_at`。公开 HTTP 入口为 `POST /api/game-distribution/games/{gameId}/plays`,完整行为见玩法链路的「游玩计数(已实现)」。
|
||||
|
||||
### `game_distribution_review`
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
| --- | --- | --- |
|
||||
| `GET /games` | 游客 | **已实现**:关键词与分类筛选,最多 48 项;仅公开可玩版本 |
|
||||
| `GET /games/{gameId}` | 游客 | **已实现**:当前公开资料与 `currentVersion.entryUrl`;不可见时 404 |
|
||||
| `POST /games/{gameId}/plays` | 游客/登录 | **已实现**:上报一次「开始游戏」;可选 Bearer,非公开 404、超限 429,成功返回 `{recorded}`;只进 api-server 内存缓冲,失败不影响游玩 |
|
||||
| `GET /game-distribution/releases/{gameId}[/{assetPath}]` | 游客 | **已实现**:根路径等价于 `index.html`;发行网关只服务当前已公开版本包内文件,按扩展名白名单设内容类型,未知扩展名 404,带 Cookie 的请求 403;游玩页的入口来自详情投影的 `currentVersion.entryUrl` |
|
||||
| `GET /my/games` | 登录作者 | **已实现**:当前账号游戏、最近版本状态与驳回理由;owner 只从认证主体派生 |
|
||||
| `POST /games` | 登录作者 | **已实现**:幂等创建游戏身份,尚不公开;带 `localProjectId` 时同一作者复用既有 `gameId` |
|
||||
@@ -136,6 +137,14 @@
|
||||
|
||||
领域规则进入 `module-*`,游戏/版本/审核/操作账本和事务进入 `spacetime-module`,访问统一通过 `spacetime-client`,HTTP 与上传编排进入 `api-server`,对象存储副作用复用 `platform-*`,跨端 DTO 同步 Rust `shared-contracts` 与 `packages/shared`。新业务必须使用当前正式表与契约,不得以未挂载源码或非正式私有快照作为公开事实;实际表字段、索引、受信服务身份及迁移清单在持久化里程碑评审时冻结。已有表若确需加字段,只能末尾追加并给明确默认值;删除/改名/重排/改类型必须另行确认迁移计划。
|
||||
|
||||
### 游玩计数(已实现)
|
||||
|
||||
- **触发点**:游玩页点击「开始游戏」时网页上报一次,不做 iframe load、不在发行网关计数、不设停留阈值;点击后即使 iframe 超时也计一次。计数失败静默,绝不阻断进入游戏。
|
||||
- **落点**:复用 `game_distribution_game.play_count` 累计次数,随目录、详情、作者「我的游戏」与后台游戏管理投影读取,不另建计数表。
|
||||
- **缓冲与延迟**:`api-server` 纯内存聚合(增量表 + 30 分钟去重表 + 限流表),flush 间隔由 `GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS` 配置(默认 5s),经批量 procedure 落库;读路径不叠加内存值,展示最多滞后一个 flush 间隔。崩溃、被杀和正常关停都允许丢最后一个未落库窗口,关停不做强制 flush。
|
||||
- **去重与限流**:登录用 `userId`、匿名用网页持久的 `clientId`、都拿不到时回退 `IP + UA`(`IP` 取 nginx 覆盖写入的 `X-Real-IP`,不取可伪造的 `X-Forwarded-For` 首段);`identity + gameId` 30 分钟去重窗口,另按 `IP + gameId` 每分钟 60 次固定窗口限流(超限 429)。公开上报端点先做内存限流预检,超限直接 429、不再查公开可见性;非公开/下架/封禁返回 404 且不计数。
|
||||
- **写入语义**:批量 procedure 在事务内只对 `published` 且存在有效公开版本的记录做 `saturating_add`,且**不更新** `updated_at`(避免重排作者列表)。该指标定位为展示用次数,不做交易级幂等、双计补偿或跨实例窗口共享。flush 按 500 分片,任一分片失败即终止本次 flush、剩余分片直接丢弃:`Build`(确定未发出)只把当前分片放回下一轮,其余错误连本批一起丢弃。
|
||||
|
||||
### 发行路径、沙箱与网络能力
|
||||
|
||||
- 发行入口是平台同源路径 `https://<平台域名>/games/<gameId>/`,边缘 nginx 把该前缀原样映射到 `api-server` 发行网关。运行隔离不依赖独立来源,而由 iframe `sandbox="allow-scripts"` 把游戏文档固定在不透明来源:游戏拿不到主站 Cookie、`localStorage`、`IndexedDB`、DOM 与 Service Worker,离开页面即随 iframe 卸载整套游戏代码。
|
||||
|
||||
@@ -255,7 +255,9 @@ function validateNginxRoutes(nginxPath, expectedRoutes, prefixRoutes) {
|
||||
fail(`${nginxPath} ${failure}`);
|
||||
}
|
||||
if (!prefixLocationBody.includes('try_files $uri /index.html =404;')) {
|
||||
fail(`${nginxPath} 前缀路由 ${prefix} 的 location 没有精确回退 index.html。`);
|
||||
fail(
|
||||
`${nginxPath} 前缀路由 ${prefix} 的 location 没有精确回退 index.html。`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
exactLocationBody?.includes(maintenanceGuard) &&
|
||||
|
||||
@@ -308,10 +308,14 @@ function validateRustMainSpaPrefixPaths() {
|
||||
const missing = expected.filter((prefix) => !rustPrefixes.includes(prefix));
|
||||
const extra = rustPrefixes.filter((prefix) => !expected.includes(prefix));
|
||||
if (missing.length > 0) {
|
||||
fail(`Pingora MAIN_SPA_PREFIX_PATHS 缺少当前前缀路由: ${missing.join(', ')}`);
|
||||
fail(
|
||||
`Pingora MAIN_SPA_PREFIX_PATHS 缺少当前前缀路由: ${missing.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
fail(`Pingora MAIN_SPA_PREFIX_PATHS 包含非当前前缀路由: ${extra.join(', ')}`);
|
||||
fail(
|
||||
`Pingora MAIN_SPA_PREFIX_PATHS 包含非当前前缀路由: ${extra.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,7 @@ describe('retired creation template module boundary plugin', () => {
|
||||
'src/persistence/storage.ts',
|
||||
'src/routing/activeAppRoutes.tsx',
|
||||
'src/services/apiClient.ts',
|
||||
'src/services/gamePlayClientId.ts',
|
||||
]) {
|
||||
expect(isRetiredFrontendModuleId(workspacePath(path))).toBe(false);
|
||||
}
|
||||
@@ -271,6 +272,7 @@ describe('retired frontend ESLint boundary', () => {
|
||||
'src/persistence/storage.ts',
|
||||
'src/routing/activeAppRoutes.tsx',
|
||||
'src/services/apiClient.ts',
|
||||
'src/services/gamePlayClientId.ts',
|
||||
]) {
|
||||
await expect(eslint.isPathIgnored(path)).resolves.toBe(false);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ pub struct AppConfig {
|
||||
pub tracking_outbox_batch_size: usize,
|
||||
pub tracking_outbox_flush_interval: Duration,
|
||||
pub tracking_outbox_max_bytes: u64,
|
||||
/// 游玩计数内存缓冲的 flush 间隔;写入是批量 procedure,间隔决定展示滞后上限。
|
||||
pub game_play_counter_flush_interval: Duration,
|
||||
pub wallet_refund_outbox_enabled: bool,
|
||||
pub wallet_refund_outbox_dir: PathBuf,
|
||||
pub wallet_refund_outbox_batch_size: usize,
|
||||
@@ -374,6 +376,7 @@ impl Default for AppConfig {
|
||||
tracking_outbox_batch_size: 500,
|
||||
tracking_outbox_flush_interval: Duration::from_millis(1_000),
|
||||
tracking_outbox_max_bytes: 256 * 1024 * 1024,
|
||||
game_play_counter_flush_interval: Duration::from_millis(5_000),
|
||||
wallet_refund_outbox_enabled: true,
|
||||
wallet_refund_outbox_dir: PathBuf::from("server-rs/.data/wallet-refund-outbox"),
|
||||
wallet_refund_outbox_batch_size: 100,
|
||||
@@ -858,6 +861,11 @@ impl AppConfig {
|
||||
{
|
||||
config.tracking_outbox_max_bytes = max_bytes;
|
||||
}
|
||||
if let Some(flush_interval_ms) =
|
||||
read_first_positive_u64_env(&["GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS"])
|
||||
{
|
||||
config.game_play_counter_flush_interval = Duration::from_millis(flush_interval_ms);
|
||||
}
|
||||
if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED"]) {
|
||||
config.wallet_refund_outbox_enabled = enabled;
|
||||
}
|
||||
@@ -2527,6 +2535,7 @@ mod tests {
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES");
|
||||
std::env::remove_var("GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS");
|
||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED");
|
||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_DIR");
|
||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE");
|
||||
@@ -2546,6 +2555,7 @@ mod tests {
|
||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE", "250");
|
||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS", "2000");
|
||||
std::env::set_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES", "1048576");
|
||||
std::env::set_var("GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS", "4000");
|
||||
std::env::set_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED", "false");
|
||||
std::env::set_var(
|
||||
"GENARRATIVE_WALLET_REFUND_OUTBOX_DIR",
|
||||
@@ -2577,6 +2587,10 @@ mod tests {
|
||||
std::time::Duration::from_millis(2_000)
|
||||
);
|
||||
assert_eq!(config.tracking_outbox_max_bytes, 1_048_576);
|
||||
assert_eq!(
|
||||
config.game_play_counter_flush_interval,
|
||||
std::time::Duration::from_millis(4_000)
|
||||
);
|
||||
assert!(!config.wallet_refund_outbox_enabled);
|
||||
assert_eq!(
|
||||
config.wallet_refund_outbox_dir,
|
||||
@@ -2601,6 +2615,7 @@ mod tests {
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS");
|
||||
std::env::remove_var("GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES");
|
||||
std::env::remove_var("GENARRATIVE_GAME_PLAY_COUNTER_FLUSH_INTERVAL_MS");
|
||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_ENABLED");
|
||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_DIR");
|
||||
std::env::remove_var("GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE");
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
//! 游戏游玩次数的进程内聚合缓冲。
|
||||
//!
|
||||
//! 只在内存里累计「开始游戏」上报:按 `identity + game_id` 做去重窗口、按 `IP + game_id` 做固定
|
||||
//! 窗口限流,并按 flush 间隔或待落库游戏数上限决定何时把增量交给写库方。
|
||||
//!
|
||||
//! 网络写入不在本模块内:`take_pending_if_due` / `requeue` 让调用方在锁外发起 procedure,锁只覆盖
|
||||
//! HashMap 操作。时间点全部由调用方传入 `now`,因此本模块不依赖运行时,可直接单测。
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Mutex, MutexGuard},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
/// 默认 flush 间隔;与 `tracking_outbox` 的秒级节奏一致,够短以保证展示及时。
|
||||
const DEFAULT_FLUSH_INTERVAL: Duration = Duration::from_secs(5);
|
||||
/// 同一 `identity + game_id` 的去重窗口。
|
||||
const DEFAULT_DEDUP_WINDOW: Duration = Duration::from_secs(30 * 60);
|
||||
/// `IP + game_id` 固定窗口长度。
|
||||
const DEFAULT_RATE_WINDOW: Duration = Duration::from_secs(60);
|
||||
/// 单个 `IP + game_id` 在每个固定窗口内允许的上报次数。
|
||||
const DEFAULT_RATE_LIMIT: u32 = 60;
|
||||
/// 待落库游戏数上限;达到后下一次检查立即 flush,而不是等满一整个间隔。
|
||||
const DEFAULT_MAX_PENDING_GAMES: usize = 4096;
|
||||
|
||||
/// 一次上报携带的最小信息。`identity` 由调用方决定:登录用户是 userId,匿名是 clientId,
|
||||
/// 都拿不到时才回退 `IP + UA`。
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct GamePlayReport<'a> {
|
||||
pub game_id: &'a str,
|
||||
pub identity: &'a str,
|
||||
pub client_ip: &'a str,
|
||||
}
|
||||
|
||||
/// 单次上报的判定结果。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GamePlayOutcome {
|
||||
/// 已计入待落库增量。
|
||||
Counted,
|
||||
/// 落在去重窗口内,未计入。
|
||||
Deduped,
|
||||
/// 超过 `IP + game_id` 固定窗口上限,未计入。
|
||||
RateLimited,
|
||||
}
|
||||
|
||||
/// 待落库的增量;同一 `game_id` 在一个批次内只出现一次。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GamePlayCountDelta {
|
||||
pub game_id: String,
|
||||
pub delta: u64,
|
||||
}
|
||||
|
||||
/// 计数器参数。默认值覆盖决策口径,`flush_interval` 由 `AppConfig` 覆盖。
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct GamePlayCounterSettings {
|
||||
pub flush_interval: Duration,
|
||||
pub dedup_window: Duration,
|
||||
pub rate_window: Duration,
|
||||
pub rate_limit: u32,
|
||||
pub max_pending_games: usize,
|
||||
}
|
||||
|
||||
impl Default for GamePlayCounterSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
flush_interval: DEFAULT_FLUSH_INTERVAL,
|
||||
dedup_window: DEFAULT_DEDUP_WINDOW,
|
||||
rate_window: DEFAULT_RATE_WINDOW,
|
||||
rate_limit: DEFAULT_RATE_LIMIT,
|
||||
max_pending_games: DEFAULT_MAX_PENDING_GAMES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 游玩计数内存缓冲。
|
||||
pub struct GamePlayCounter {
|
||||
settings: GamePlayCounterSettings,
|
||||
inner: Mutex<GamePlayCounterState>,
|
||||
}
|
||||
|
||||
struct GamePlayCounterState {
|
||||
/// `game_id -> 待落库增量`;只包含通过校验的上报,键空间由公开游戏目录界定。
|
||||
pending: HashMap<String, u64>,
|
||||
/// `(identity, game_id) -> 最近一次计数时间`。
|
||||
seen: HashMap<(String, String), Instant>,
|
||||
/// `(IP, game_id) -> 当前固定窗口`。
|
||||
rate: HashMap<(String, String), RateWindow>,
|
||||
/// 最近一次真正取走增量的时间,用于判断是否到达 flush 间隔。
|
||||
last_flush_at: Instant,
|
||||
}
|
||||
|
||||
struct RateWindow {
|
||||
started_at: Instant,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
impl GamePlayCounter {
|
||||
pub fn new(settings: GamePlayCounterSettings, now: Instant) -> Self {
|
||||
Self {
|
||||
settings,
|
||||
inner: Mutex::new(GamePlayCounterState {
|
||||
pending: HashMap::new(),
|
||||
seen: HashMap::new(),
|
||||
rate: HashMap::new(),
|
||||
last_flush_at: now,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flush_interval(&self) -> Duration {
|
||||
self.settings.flush_interval
|
||||
}
|
||||
|
||||
/// 处理一次上报:先去重,再限流,最后累加增量。
|
||||
///
|
||||
/// 被去重命中的上报不消耗限流额度;限流只挡同一 IP 对同一游戏的超额上报。
|
||||
pub fn record(&self, report: GamePlayReport<'_>, now: Instant) -> GamePlayOutcome {
|
||||
// 键用元组而不是拼接字符串:clientId 来自 JSON,可能包含任意字节(含 U+001F),
|
||||
// 拼接会产生本不存在的键碰撞。
|
||||
let dedup_key = (report.identity.to_string(), report.game_id.to_string());
|
||||
let rate_key = (report.client_ip.to_string(), report.game_id.to_string());
|
||||
let mut state = self.lock();
|
||||
|
||||
if let Some(seen_at) = state.seen.get(&dedup_key)
|
||||
&& now.saturating_duration_since(*seen_at) < self.settings.dedup_window
|
||||
{
|
||||
return GamePlayOutcome::Deduped;
|
||||
}
|
||||
|
||||
let rate = state.rate.entry(rate_key).or_insert_with(|| RateWindow {
|
||||
started_at: now,
|
||||
count: 0,
|
||||
});
|
||||
if now.saturating_duration_since(rate.started_at) >= self.settings.rate_window {
|
||||
rate.started_at = now;
|
||||
rate.count = 0;
|
||||
}
|
||||
if rate.count >= self.settings.rate_limit {
|
||||
return GamePlayOutcome::RateLimited;
|
||||
}
|
||||
rate.count = rate.count.saturating_add(1);
|
||||
|
||||
state.seen.insert(dedup_key, now);
|
||||
let pending = state.pending.entry(report.game_id.to_string()).or_insert(0);
|
||||
*pending = pending.saturating_add(1);
|
||||
GamePlayOutcome::Counted
|
||||
}
|
||||
|
||||
/// 限流预检:只读,不消耗额度、不改任何状态。
|
||||
///
|
||||
/// 公开上报接口在昂贵的公开可见性查询之前先用它挡掉明显超限的请求;真正计数时
|
||||
/// `record` 仍会重新判定一次,所以这里只用于省一次远端查询,不承担正确性。
|
||||
pub fn is_rate_limited(&self, game_id: &str, client_ip: &str, now: Instant) -> bool {
|
||||
let key = (client_ip.to_string(), game_id.to_string());
|
||||
let state = self.lock();
|
||||
state.rate.get(&key).is_some_and(|window| {
|
||||
now.saturating_duration_since(window.started_at) < self.settings.rate_window
|
||||
&& window.count >= self.settings.rate_limit
|
||||
})
|
||||
}
|
||||
|
||||
/// 到达 flush 间隔或待落库游戏数达到上限时取走全部增量;否则返回 `None`。
|
||||
pub fn take_pending_if_due(&self, now: Instant) -> Option<Vec<GamePlayCountDelta>> {
|
||||
let mut state = self.lock();
|
||||
if state.pending.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let interval_elapsed =
|
||||
now.saturating_duration_since(state.last_flush_at) >= self.settings.flush_interval;
|
||||
let at_capacity = state.pending.len() >= self.settings.max_pending_games;
|
||||
if !interval_elapsed && !at_capacity {
|
||||
return None;
|
||||
}
|
||||
state.last_flush_at = now;
|
||||
let deltas = drain_pending(&mut state.pending);
|
||||
drop(state);
|
||||
Some(sort_deltas(deltas))
|
||||
}
|
||||
|
||||
/// 无条件取走全部增量;仅供测试使用,生产路径走 `take_pending_if_due`。
|
||||
#[cfg(test)]
|
||||
pub fn take_pending(&self) -> Vec<GamePlayCountDelta> {
|
||||
let mut state = self.lock();
|
||||
let deltas = drain_pending(&mut state.pending);
|
||||
drop(state);
|
||||
sort_deltas(deltas)
|
||||
}
|
||||
|
||||
/// 写库失败时把增量放回,等待下一次 flush。
|
||||
pub fn requeue(&self, deltas: &[GamePlayCountDelta]) {
|
||||
let mut state = self.lock();
|
||||
for delta in deltas {
|
||||
let pending = state.pending.entry(delta.game_id.clone()).or_insert(0);
|
||||
*pending = pending.saturating_add(delta.delta);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清掉过期的去重与限流条目,避免 map 无界增长。
|
||||
pub fn prune_expired(&self, now: Instant) {
|
||||
let mut state = self.lock();
|
||||
let dedup_window = self.settings.dedup_window;
|
||||
let rate_window = self.settings.rate_window;
|
||||
state
|
||||
.seen
|
||||
.retain(|_, seen_at| now.saturating_duration_since(*seen_at) < dedup_window);
|
||||
state
|
||||
.rate
|
||||
.retain(|_, window| now.saturating_duration_since(window.started_at) < rate_window);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn pending_game_count(&self) -> usize {
|
||||
self.lock().pending.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn pending_total(&self) -> u64 {
|
||||
self.lock().pending.values().copied().sum()
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, GamePlayCounterState> {
|
||||
// 计数是尽力而为的展示指标:锁中毒时继续用内部状态,不让一次 panic 永久关闭计数。
|
||||
// 但中毒意味着上一次 panic 可能留下部分更新的状态,必须留下可关联的日志。
|
||||
self.inner.lock().unwrap_or_else(|poisoned| {
|
||||
warn!("游戏游玩计数锁已中毒,继续使用内部状态");
|
||||
poisoned.into_inner()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_pending(pending: &mut HashMap<String, u64>) -> Vec<GamePlayCountDelta> {
|
||||
pending
|
||||
.drain()
|
||||
.filter_map(|(game_id, delta)| (delta > 0).then_some(GamePlayCountDelta { game_id, delta }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 稳定批次顺序,便于测试与日志比对;调用方已释放计数锁,排序不阻塞并发 `record`。
|
||||
fn sort_deltas(mut deltas: Vec<GamePlayCountDelta>) -> Vec<GamePlayCountDelta> {
|
||||
deltas.sort_by(|left, right| left.game_id.cmp(&right.game_id));
|
||||
deltas
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn settings() -> GamePlayCounterSettings {
|
||||
GamePlayCounterSettings {
|
||||
flush_interval: Duration::from_secs(5),
|
||||
dedup_window: Duration::from_secs(30 * 60),
|
||||
rate_window: Duration::from_secs(60),
|
||||
rate_limit: 3,
|
||||
max_pending_games: 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn report<'a>(game_id: &'a str, identity: &'a str, client_ip: &'a str) -> GamePlayReport<'a> {
|
||||
GamePlayReport {
|
||||
game_id,
|
||||
identity,
|
||||
client_ip,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_report_within_dedup_window_is_deduped() {
|
||||
let start = Instant::now();
|
||||
let counter = GamePlayCounter::new(settings(), start);
|
||||
|
||||
assert_eq!(
|
||||
counter.record(report("g1", "u1", "1.1.1.1"), start),
|
||||
GamePlayOutcome::Counted
|
||||
);
|
||||
assert_eq!(
|
||||
counter.record(
|
||||
report("g1", "u1", "1.1.1.1"),
|
||||
start + Duration::from_secs(60)
|
||||
),
|
||||
GamePlayOutcome::Deduped
|
||||
);
|
||||
assert_eq!(counter.pending_total(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_expires_after_window() {
|
||||
let start = Instant::now();
|
||||
let counter = GamePlayCounter::new(settings(), start);
|
||||
|
||||
counter.record(report("g1", "u1", "1.1.1.1"), start);
|
||||
assert_eq!(
|
||||
counter.record(
|
||||
report("g1", "u1", "1.1.1.1"),
|
||||
start + Duration::from_secs(30 * 60)
|
||||
),
|
||||
GamePlayOutcome::Counted
|
||||
);
|
||||
assert_eq!(counter.pending_total(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_identities_count_separately() {
|
||||
let start = Instant::now();
|
||||
let counter = GamePlayCounter::new(settings(), start);
|
||||
|
||||
counter.record(report("g1", "u1", "1.1.1.1"), start);
|
||||
counter.record(report("g1", "u2", "1.1.1.1"), start);
|
||||
assert_eq!(counter.pending_total(), 2);
|
||||
assert_eq!(counter.pending_game_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_blocks_excess_and_resets_after_window() {
|
||||
let start = Instant::now();
|
||||
let counter = GamePlayCounter::new(settings(), start);
|
||||
|
||||
for index in 0..3 {
|
||||
assert_eq!(
|
||||
counter.record(report("g1", &format!("u{index}"), "1.1.1.1"), start),
|
||||
GamePlayOutcome::Counted
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
counter.record(report("g1", "u9", "1.1.1.1"), start),
|
||||
GamePlayOutcome::RateLimited
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
counter.record(
|
||||
report("g1", "u9", "1.1.1.1"),
|
||||
start + Duration::from_secs(60)
|
||||
),
|
||||
GamePlayOutcome::Counted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_precheck_is_read_only_and_window_scoped() {
|
||||
let start = Instant::now();
|
||||
let counter = GamePlayCounter::new(settings(), start);
|
||||
|
||||
assert!(!counter.is_rate_limited("g1", "1.1.1.1", start));
|
||||
for index in 0..3 {
|
||||
counter.record(report("g1", &format!("u{index}"), "1.1.1.1"), start);
|
||||
}
|
||||
|
||||
// 预检只读:连续调用不消耗额度,也不改变判定。
|
||||
assert!(counter.is_rate_limited("g1", "1.1.1.1", start));
|
||||
assert!(counter.is_rate_limited("g1", "1.1.1.1", start));
|
||||
// 另一个 IP、另一个游戏都不受影响。
|
||||
assert!(!counter.is_rate_limited("g1", "2.2.2.2", start));
|
||||
assert!(!counter.is_rate_limited("g2", "1.1.1.1", start));
|
||||
// 固定窗口结束后恢复。
|
||||
assert!(!counter.is_rate_limited("g1", "1.1.1.1", start + Duration::from_secs(60)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_pending_if_due_waits_for_interval_then_drains() {
|
||||
let start = Instant::now();
|
||||
let counter = GamePlayCounter::new(settings(), start);
|
||||
|
||||
counter.record(report("g1", "u1", "1.1.1.1"), start);
|
||||
assert!(
|
||||
counter
|
||||
.take_pending_if_due(start + Duration::from_secs(4))
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let deltas = counter
|
||||
.take_pending_if_due(start + Duration::from_secs(5))
|
||||
.expect("到达间隔后应当取走增量");
|
||||
assert_eq!(
|
||||
deltas,
|
||||
vec![GamePlayCountDelta {
|
||||
game_id: "g1".to_string(),
|
||||
delta: 1,
|
||||
}]
|
||||
);
|
||||
assert_eq!(counter.pending_total(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_triggers_early_flush() {
|
||||
let start = Instant::now();
|
||||
let counter = GamePlayCounter::new(settings(), start);
|
||||
|
||||
for index in 0..4u64 {
|
||||
counter.record(
|
||||
report(&format!("g{index}"), "u1", "1.1.1.1"),
|
||||
start + Duration::from_millis(index * 10),
|
||||
);
|
||||
}
|
||||
let deltas = counter
|
||||
.take_pending_if_due(start + Duration::from_secs(1))
|
||||
.expect("达到待落库游戏数上限应当立即 flush");
|
||||
assert_eq!(deltas.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregates_per_game_and_sorts() {
|
||||
let start = Instant::now();
|
||||
let counter = GamePlayCounter::new(settings(), start);
|
||||
|
||||
counter.record(report("g2", "u1", "1.1.1.1"), start);
|
||||
counter.record(report("g1", "u2", "1.1.1.1"), start);
|
||||
counter.record(report("g2", "u2", "1.1.1.1"), start);
|
||||
|
||||
let deltas = counter.take_pending();
|
||||
assert_eq!(
|
||||
deltas,
|
||||
vec![
|
||||
GamePlayCountDelta {
|
||||
game_id: "g1".to_string(),
|
||||
delta: 1,
|
||||
},
|
||||
GamePlayCountDelta {
|
||||
game_id: "g2".to_string(),
|
||||
delta: 2,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requeue_restores_deltas() {
|
||||
let start = Instant::now();
|
||||
let counter = GamePlayCounter::new(settings(), start);
|
||||
|
||||
counter.record(report("g1", "u1", "1.1.1.1"), start);
|
||||
let deltas = counter.take_pending();
|
||||
assert_eq!(counter.pending_total(), 0);
|
||||
|
||||
counter.requeue(&deltas);
|
||||
assert_eq!(counter.pending_total(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_expired_drops_old_dedup_and_rate_entries() {
|
||||
let start = Instant::now();
|
||||
let counter = GamePlayCounter::new(settings(), start);
|
||||
|
||||
counter.record(report("g1", "u1", "1.1.1.1"), start);
|
||||
counter.prune_expired(start + Duration::from_secs(30 * 60 + 1));
|
||||
|
||||
// 去重条目过期后同一身份还能重新计数。
|
||||
assert_eq!(
|
||||
counter.record(
|
||||
report("g1", "u1", "1.1.1.1"),
|
||||
start + Duration::from_secs(30 * 60 + 2)
|
||||
),
|
||||
GamePlayOutcome::Counted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separator_like_bytes_in_key_parts_do_not_collide() {
|
||||
let start = Instant::now();
|
||||
let counter = GamePlayCounter::new(settings(), start);
|
||||
|
||||
// 旧实现用 U+001F 拼接 identity/game_id,下面两个不同元组会被拼成同一个键。
|
||||
assert_eq!(
|
||||
counter.record(report("b\u{1f}c", "a", "1.1.1.1"), start),
|
||||
GamePlayOutcome::Counted
|
||||
);
|
||||
assert_eq!(
|
||||
counter.record(report("c", "a\u{1f}b", "1.1.1.1"), start),
|
||||
GamePlayOutcome::Counted
|
||||
);
|
||||
assert_eq!(counter.pending_total(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! 游玩计数的 flush worker:按间隔把内存增量批量写进 SpacetimeDB。
|
||||
//!
|
||||
//! 失败语义与 ADR 一致:连接还没建起来(`Build`)属于确定未发出,交给计数器放回下一轮重试;
|
||||
//! 其余错误(`Timeout` / `ConnectDropped` / `Procedure`)无法判断是否已提交,直接丢弃该批并
|
||||
//! 记录丢失量,避免系统性双计。
|
||||
//!
|
||||
//! 任一分片失败都会终止本次 flush 的后续分片:连接不通时剩余分片只会重复同样的失败,逐个重试
|
||||
//! 会把 worker 卡在多次连接超时上;剩余增量按“直接丢弃”处理,尽快回到 tick。
|
||||
//!
|
||||
//! 进程关停不做强制 flush:内存里剩下的增量随进程结束丢弃,关停路径不为它等待网络。
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use spacetime_client::{GameDistributionPlayCountIncrementRecordInput, SpacetimeClientError};
|
||||
use tokio::time::sleep;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{game_play_counter::GamePlayCountDelta, state::AppState};
|
||||
|
||||
/// 一次 procedure 最多携带多少条游戏增量;只影响帧大小,不影响累计结果。
|
||||
pub(crate) const GAME_PLAY_COUNT_FLUSH_BATCH_SIZE: usize = 500;
|
||||
|
||||
/// worker 检查节拍:比默认 flush 间隔细,以便待落库游戏数达到上限时提前 flush。
|
||||
const GAME_PLAY_COUNTER_TICK: Duration = Duration::from_secs(1);
|
||||
|
||||
/// 启动 flush worker;只在 HTTP 角色进程注册一次。
|
||||
pub(crate) fn spawn_game_play_counter_worker(state: AppState) {
|
||||
let tick = GAME_PLAY_COUNTER_TICK.min(state.game_play_counter().flush_interval());
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
sleep(tick).await;
|
||||
let now = Instant::now();
|
||||
let counter = state.game_play_counter();
|
||||
counter.prune_expired(now);
|
||||
if let Some(deltas) = counter.take_pending_if_due(now) {
|
||||
flush_deltas(&state, deltas).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 把一批增量写库。
|
||||
///
|
||||
/// `Build`(确定未发出)放回等待下一轮;其余错误无法判断是否已提交,直接丢弃并记丢失量。
|
||||
async fn flush_deltas(state: &AppState, deltas: Vec<GamePlayCountDelta>) {
|
||||
let mut accepted = 0usize;
|
||||
let mut dropped = 0u64;
|
||||
let mut start = 0usize;
|
||||
while start < deltas.len() {
|
||||
let end = (start + GAME_PLAY_COUNT_FLUSH_BATCH_SIZE).min(deltas.len());
|
||||
let chunk = &deltas[start..end];
|
||||
match write_batch(state, chunk).await {
|
||||
Ok(()) => {
|
||||
accepted += chunk.len();
|
||||
start = end;
|
||||
}
|
||||
// 连接没建起来(确定未发出):本批放回,但后面的分片会重复同样的失败,
|
||||
// 直接丢弃剩余,避免每个分片各等一次连接超时把 worker 卡住。
|
||||
Err(SpacetimeClientError::Build(message)) => {
|
||||
state.game_play_counter().requeue(chunk);
|
||||
let lost = total_delta(&deltas[end..]);
|
||||
dropped = dropped.saturating_add(lost);
|
||||
warn!(
|
||||
error = %message,
|
||||
games = chunk.len(),
|
||||
lost,
|
||||
"游戏游玩计数写入连接未建立:本批放回等待下一轮,剩余增量直接丢弃"
|
||||
);
|
||||
break;
|
||||
}
|
||||
// 结果未知:连本批一起丢弃剩余,避免双计,也不再把 worker 卡在逐个重连上。
|
||||
Err(error) => {
|
||||
let lost = total_delta(&deltas[start..]);
|
||||
dropped = dropped.saturating_add(lost);
|
||||
warn!(
|
||||
error = %error,
|
||||
games = deltas.len() - start,
|
||||
lost,
|
||||
"游戏游玩计数写入失败,剩余增量直接丢弃"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if accepted > 0 {
|
||||
info!(games = accepted, "游戏游玩计数已批量落库");
|
||||
}
|
||||
if dropped > 0 {
|
||||
warn!(dropped, "游戏游玩计数存在丢弃量");
|
||||
}
|
||||
}
|
||||
|
||||
fn total_delta(deltas: &[GamePlayCountDelta]) -> u64 {
|
||||
deltas.iter().map(|delta| delta.delta).sum()
|
||||
}
|
||||
|
||||
async fn write_batch(
|
||||
state: &AppState,
|
||||
deltas: &[GamePlayCountDelta],
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
let increments = deltas
|
||||
.iter()
|
||||
.map(|delta| GameDistributionPlayCountIncrementRecordInput {
|
||||
game_id: delta.game_id.clone(),
|
||||
delta: delta.delta,
|
||||
})
|
||||
.collect();
|
||||
state
|
||||
.spacetime_client()
|
||||
.increment_game_distribution_game_play_counts(increments)
|
||||
.await
|
||||
}
|
||||
@@ -48,6 +48,8 @@ mod external_generation_worker_controller;
|
||||
mod external_mcp;
|
||||
mod external_skill_api;
|
||||
mod frontend_runtime_config;
|
||||
mod game_play_counter;
|
||||
mod game_play_counter_worker;
|
||||
mod generated_image_assets;
|
||||
mod health;
|
||||
mod http_error;
|
||||
@@ -700,6 +702,7 @@ fn spawn_http_app_state_background_workers(state: &AppState, process_role: Proce
|
||||
crate::payment_webhook::PaymentWebhookWorker::new(state.spacetime_client().clone())
|
||||
.spawn_worker();
|
||||
crate::error_reports::spawn_cleanup_worker(state.clone());
|
||||
crate::game_play_counter_worker::spawn_game_play_counter_worker(state.clone());
|
||||
if should_start_profile_recharge_expiration_listener(process_role) {
|
||||
spawn_profile_recharge_expiration_listener(state.clone());
|
||||
spawn_profile_recharge_refund_reconciliation_worker(state.clone());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, VecDeque},
|
||||
sync::{Arc, Mutex, OnceLock},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
time::{Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
@@ -62,10 +62,11 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
admin::{AuthenticatedAdmin, require_admin_auth},
|
||||
api_response::json_success_body,
|
||||
auth::{AuthenticatedAccessToken, require_bearer_auth},
|
||||
auth::{AuthenticatedAccessToken, optional_access_token_from_headers, require_bearer_auth},
|
||||
game_play_counter::{GamePlayOutcome, GamePlayReport},
|
||||
http_error::AppError,
|
||||
platform_errors::{map_llm_error, map_oss_error},
|
||||
request_context::RequestContext,
|
||||
request_context::{RequestContext, client_ip_from_headers},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -344,6 +345,10 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
let public_games = Router::new()
|
||||
.route("/api/game-distribution/games", get(list_games))
|
||||
.route("/api/game-distribution/games/{game_id}", get(get_game))
|
||||
.route(
|
||||
"/api/game-distribution/games/{game_id}/plays",
|
||||
post(record_game_play),
|
||||
)
|
||||
.route_layer(middleware::from_fn(add_no_store_response_headers));
|
||||
|
||||
Router::new()
|
||||
@@ -931,6 +936,112 @@ async fn get_game(
|
||||
Ok(json_success_body(Some(&ctx), public_game_payload(game)))
|
||||
}
|
||||
|
||||
/// 一次游玩上报的请求体;只有匿名身份需要 `clientId`,登录身份由 bearer 决定。
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RecordGamePlayRequest {
|
||||
#[serde(default)]
|
||||
client_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 记录一次「开始游戏」。
|
||||
///
|
||||
/// 公开端点:登录用户按 `userId` 去重,匿名按 `clientId`(缺失时回退 `IP + UA`)去重;
|
||||
/// 命中 30 分钟去重窗口或超过 `IP + game` 限流时不增加计数。计数只进内存缓冲,
|
||||
/// 立即返回 `recorded`,任何失败都不影响游玩本身。
|
||||
async fn record_game_play(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<RequestContext>,
|
||||
Path(game_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let game_id = game_id.trim().to_string();
|
||||
if game_id.is_empty() {
|
||||
return Err(AppError::from_status(StatusCode::NOT_FOUND));
|
||||
}
|
||||
|
||||
let client_ip = client_ip_from_headers(&headers);
|
||||
// 先在内存里挡掉明显超限的请求,避免它们也去打一次 SpacetimeDB;真正计数时 record 会再判一次。
|
||||
if state
|
||||
.game_play_counter()
|
||||
.is_rate_limited(&game_id, &client_ip, Instant::now())
|
||||
{
|
||||
return Err(AppError::from_status(StatusCode::TOO_MANY_REQUESTS));
|
||||
}
|
||||
|
||||
// 非公开 / 已下架 / 已暂停的游戏不计数,按不存在返回。
|
||||
let is_public = state
|
||||
.spacetime_client()
|
||||
.get_public_game_distribution_game(game_id.clone())
|
||||
.await
|
||||
.map_err(map_spacetime_error)?
|
||||
.is_some();
|
||||
if !is_public {
|
||||
return Err(AppError::from_status(StatusCode::NOT_FOUND));
|
||||
}
|
||||
|
||||
let user_agent = user_agent_tag(&headers);
|
||||
let authenticated = optional_access_token_from_headers(
|
||||
&state,
|
||||
format!("/api/game-distribution/games/{game_id}/plays"),
|
||||
headers,
|
||||
ctx.request_id().to_string(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
// 可选 bearer:无效 token 按匿名处理,绝不能因为它挡掉一次真实游玩。
|
||||
debug!(error = %error, "游戏游玩计数忽略无效 bearer,按匿名计数");
|
||||
None
|
||||
});
|
||||
let identity = authenticated
|
||||
.as_ref()
|
||||
.map(|token| format!("user:{}", token.claims().user_id()))
|
||||
.or_else(|| request_client_id(&body).map(|client_id| format!("client:{client_id}")))
|
||||
.unwrap_or_else(|| format!("ip:{client_ip}|ua:{user_agent}"));
|
||||
|
||||
let outcome = state.game_play_counter().record(
|
||||
GamePlayReport {
|
||||
game_id: &game_id,
|
||||
identity: &identity,
|
||||
client_ip: &client_ip,
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
if outcome == GamePlayOutcome::RateLimited {
|
||||
return Err(AppError::from_status(StatusCode::TOO_MANY_REQUESTS));
|
||||
}
|
||||
Ok(json_success_body(
|
||||
Some(&ctx),
|
||||
json!({ "recorded": outcome == GamePlayOutcome::Counted }),
|
||||
))
|
||||
}
|
||||
|
||||
fn request_client_id(body: &Bytes) -> Option<String> {
|
||||
if body.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let request = serde_json::from_slice::<RecordGamePlayRequest>(body).ok()?;
|
||||
request
|
||||
.client_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.chars().take(128).collect())
|
||||
}
|
||||
|
||||
fn user_agent_tag(headers: &HeaderMap) -> String {
|
||||
headers
|
||||
.get(header::USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("unknown")
|
||||
.chars()
|
||||
.take(64)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 作者自有游戏列表:只返回当前认证主体名下的游戏与最近版本状态。
|
||||
async fn list_my_games(
|
||||
State(state): State<AppState>,
|
||||
@@ -4076,4 +4187,53 @@ mod tests {
|
||||
assert_eq!(generic.summary, "一款由陶泥儿创作的益智游戏");
|
||||
assert_eq!(generic.category, "益智");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn game_play_route_is_public_and_no_store_without_spacetime_connection() {
|
||||
use axum::http::Request;
|
||||
use tower::ServiceExt;
|
||||
|
||||
let app =
|
||||
crate::app::build_router(AppState::new(crate::config::AppConfig::default()).unwrap());
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/game-distribution/games/game_1/plays")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"clientId":"client-1"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// 未连接 SpacetimeDB 时公开可见性读取失败,但路由可达且不需要登录;只有确认公开后才计数。
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_request_client_id_trims_limits_and_rejects_blank() {
|
||||
assert_eq!(request_client_id(&Bytes::from_static(b"")), None);
|
||||
assert_eq!(request_client_id(&Bytes::from_static(b"not json")), None);
|
||||
assert_eq!(request_client_id(&Bytes::from_static(b"{}")), None);
|
||||
assert_eq!(
|
||||
request_client_id(&Bytes::from_static(br#"{"clientId":" abc "}"#)),
|
||||
Some("abc".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
request_client_id(&Bytes::from_static(br#"{"clientId":" "}"#)),
|
||||
None
|
||||
);
|
||||
let long = "x".repeat(200);
|
||||
let body = Bytes::from(format!(r#"{{"clientId":"{long}"}}"#));
|
||||
assert_eq!(request_client_id(&body).unwrap().chars().count(), 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_report_user_agent_is_bounded_and_falls_back() {
|
||||
assert_eq!(user_agent_tag(&HeaderMap::new()), "unknown");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::USER_AGENT, " test-agent ".parse().unwrap());
|
||||
assert_eq!(user_agent_tag(&headers), "test-agent");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{HeaderValue, Request as HttpRequest, header::HeaderName},
|
||||
http::{HeaderMap, HeaderValue, Request as HttpRequest, header::HeaderName},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
@@ -107,6 +107,29 @@ pub async fn attach_request_context(mut request: Request, next: Next) -> Respons
|
||||
.await
|
||||
}
|
||||
|
||||
/// 从代理头解析客户端 IP。
|
||||
///
|
||||
/// 优先 `x-real-ip`:nginx 用 `$remote_addr` 覆盖写入,是真实 TCP 对端,调用方无法伪造。
|
||||
/// `x-forwarded-for` 只作回退,并取**最后一段**——nginx 用 `$proxy_add_x_forwarded_for` 会把真实
|
||||
/// 对端追加在末尾,前面几段是调用方自带的、可伪造。两者都拿不到时才兜底回环地址。
|
||||
pub fn client_ip_from_headers(headers: &HeaderMap) -> String {
|
||||
headers
|
||||
.get("x-real-ip")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.rsplit(',').next())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.unwrap_or("127.0.0.1")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn resolve_request_id<B>(request: &HttpRequest<B>) -> Option<String> {
|
||||
request
|
||||
.extensions()
|
||||
@@ -148,4 +171,35 @@ mod tests {
|
||||
|
||||
assert_eq!(context.external_call_deadline(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_ip_prefers_real_ip_over_forwarded_for() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
HeaderValue::from_static("203.0.113.7, 10.0.0.1"),
|
||||
);
|
||||
headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.9"));
|
||||
assert_eq!(client_ip_from_headers(&headers), "198.51.100.9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_ip_forwarded_for_fallback_uses_last_address() {
|
||||
// nginx 把真实对端追加在末尾,前面是调用方可伪造的值,只能取最后一段。
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
HeaderValue::from_static("203.0.113.7, 10.0.0.1"),
|
||||
);
|
||||
assert_eq!(client_ip_from_headers(&headers), "10.0.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_ip_ignores_blank_real_ip_and_falls_back_then_loopback() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-real-ip", HeaderValue::from_static(" "));
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("10.0.0.1"));
|
||||
assert_eq!(client_ip_from_headers(&headers), "10.0.0.1");
|
||||
assert_eq!(client_ip_from_headers(&HeaderMap::new()), "127.0.0.1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1632,21 +1632,7 @@ fn is_wechat_recharge_payment_channel(payment_channel: &str) -> bool {
|
||||
}
|
||||
|
||||
fn resolve_wechat_pay_client_ip(headers: &HeaderMap) -> String {
|
||||
headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.split(',').next())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| {
|
||||
headers
|
||||
.get("x-real-ip")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.unwrap_or("127.0.0.1")
|
||||
.to_string()
|
||||
crate::request_context::client_ip_from_headers(headers)
|
||||
}
|
||||
|
||||
async fn resolve_wechat_identity_for_payment(
|
||||
|
||||
@@ -45,6 +45,7 @@ use crate::editor_generation_config::{
|
||||
EditorGenerationPricingConfig, EditorGenerationPricingError, EditorGenerationPricingStore,
|
||||
EditorGenerationPricingUnit,
|
||||
};
|
||||
use crate::game_play_counter::{GamePlayCounter, GamePlayCounterSettings};
|
||||
use crate::tracking_outbox::TrackingOutbox;
|
||||
use crate::wallet_refund_outbox::{ProfileWalletRefundOutboxWorker, WalletRefundOutbox};
|
||||
use crate::wechat::pay::{build_wechat_pay_config, map_wechat_pay_init_error};
|
||||
@@ -312,6 +313,8 @@ pub struct AppStateInner {
|
||||
tracking_outbox: Option<Arc<TrackingOutbox>>,
|
||||
wallet_refund_outbox: Option<Arc<WalletRefundOutbox>>,
|
||||
profile_wallet_refund_outbox_worker: Arc<ProfileWalletRefundOutboxWorker>,
|
||||
/// 游玩计数的进程内聚合缓冲;写入由 `game_play_counter_worker` 负责。
|
||||
game_play_counter: GamePlayCounter,
|
||||
editor_generation_pricing_store: EditorGenerationPricingStore,
|
||||
llm_client: Option<LlmClient>,
|
||||
vector_engine_llm_client: Option<LlmClient>,
|
||||
@@ -617,6 +620,13 @@ impl AppState {
|
||||
WalletRefundOutbox::from_config(&config, spacetime_client.clone());
|
||||
let profile_wallet_refund_outbox_worker =
|
||||
ProfileWalletRefundOutboxWorker::from_config(&config, spacetime_client.clone());
|
||||
let game_play_counter = GamePlayCounter::new(
|
||||
GamePlayCounterSettings {
|
||||
flush_interval: config.game_play_counter_flush_interval,
|
||||
..GamePlayCounterSettings::default()
|
||||
},
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
let editor_generation_pricing_store = EditorGenerationPricingStore::load(
|
||||
config.editor_generation_pricing_override_path.clone(),
|
||||
)
|
||||
@@ -713,6 +723,7 @@ impl AppState {
|
||||
tracking_outbox,
|
||||
wallet_refund_outbox,
|
||||
profile_wallet_refund_outbox_worker,
|
||||
game_play_counter,
|
||||
editor_generation_pricing_store,
|
||||
llm_client,
|
||||
vector_engine_llm_client,
|
||||
@@ -1691,6 +1702,10 @@ impl AppState {
|
||||
self.profile_wallet_refund_outbox_worker.clone()
|
||||
}
|
||||
|
||||
pub fn game_play_counter(&self) -> &GamePlayCounter {
|
||||
&self.game_play_counter
|
||||
}
|
||||
|
||||
pub fn llm_client(&self) -> Option<&LlmClient> {
|
||||
self.llm_client.as_ref()
|
||||
}
|
||||
|
||||
@@ -27,10 +27,11 @@ pub use game_distribution::{
|
||||
GameDistributionConfirmPackageRecordInput, GameDistributionCreateGameRecordInput,
|
||||
GameDistributionCreateVersionRecordInput, GameDistributionFailUploadRecordInput,
|
||||
GameDistributionGetGameRecordInput, GameDistributionOwnerGameListRecordInput,
|
||||
GameDistributionPublicGameListRecordInput, GameDistributionRejectRecordInput,
|
||||
GameDistributionRestoreRecordInput, GameDistributionReviewGameListRecordInput,
|
||||
GameDistributionReviewModerationRecordInput, GameDistributionSubmitReviewRecordInput,
|
||||
GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput,
|
||||
GameDistributionPlayCountIncrementRecordInput, GameDistributionPublicGameListRecordInput,
|
||||
GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput,
|
||||
GameDistributionReviewGameListRecordInput, GameDistributionReviewModerationRecordInput,
|
||||
GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput,
|
||||
GameDistributionUnpublishRecordInput,
|
||||
};
|
||||
#[path = "active/external_generation.rs"]
|
||||
pub mod external_generation;
|
||||
|
||||
@@ -51,6 +51,13 @@ pub struct GameDistributionGetGameRecordInput {
|
||||
pub owner_user_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 单次游玩计数增量;同一批次内每个 `game_id` 只出现一次。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GameDistributionPlayCountIncrementRecordInput {
|
||||
pub game_id: String,
|
||||
pub delta: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GameDistributionCreateGameRecordInput {
|
||||
pub game_id: String,
|
||||
@@ -560,6 +567,49 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
/// 批量累加已公开游戏的游玩次数;非公开游戏由事务静默跳过。
|
||||
pub async fn increment_game_distribution_game_play_counts(
|
||||
&self,
|
||||
increments: Vec<GameDistributionPlayCountIncrementRecordInput>,
|
||||
) -> Result<(), SpacetimeClientError> {
|
||||
let input = crate::module_bindings::GameDistributionPlayCountIncrementInput {
|
||||
increments: increments
|
||||
.into_iter()
|
||||
.map(
|
||||
|increment| crate::module_bindings::GameDistributionPlayCountIncrement {
|
||||
game_id: increment.game_id,
|
||||
delta: increment.delta,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
};
|
||||
self.call_after_connect(
|
||||
"increment_game_distribution_game_play_counts",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.increment_game_distribution_game_play_counts_and_return_then(
|
||||
input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(|result| {
|
||||
if result.ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SpacetimeClientError::procedure_failed(
|
||||
result.error_message,
|
||||
))
|
||||
}
|
||||
});
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_game_distribution_game(
|
||||
&self,
|
||||
input: GameDistributionCreateGameRecordInput,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user