Merge remote-tracking branch 'origin/codex/gray-release'
This commit is contained in:
@@ -22,6 +22,7 @@ import type {
|
||||
AdminEditorShowcaseListQuery,
|
||||
AdminEditorShowcaseListResponse,
|
||||
AdminEditorShowcaseReviewRequest,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
@@ -32,6 +33,7 @@ import type {
|
||||
AdminUpdateWorkVisibilityResponse,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertFeatureGateConfigRequest,
|
||||
AdminUpsertProfileInviteCodeRequest,
|
||||
AdminUpsertProfileRechargeProductRequest,
|
||||
AdminUpsertProfileRedeemCodeRequest,
|
||||
@@ -230,6 +232,23 @@ export function listAdminTrackingEventKeys(token: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminFeatureGateConfig(token: string) {
|
||||
return request<AdminFeatureGateConfigResponse>('/admin/api/feature-gates', {
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertAdminFeatureGateConfig(
|
||||
token: string,
|
||||
payload: AdminUpsertFeatureGateConfigRequest,
|
||||
) {
|
||||
return request<AdminFeatureGateConfigResponse>('/admin/api/feature-gates', {
|
||||
method: 'PUT',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminCreationEntryConfig(token: string) {
|
||||
return request<AdminCreationEntryConfigResponse>(
|
||||
'/admin/api/creation-entry/config',
|
||||
|
||||
@@ -212,6 +212,26 @@ export interface AdminTrackingEventListQuery {
|
||||
exportAll?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminFeatureGateConfigPayload {
|
||||
gateKey: string;
|
||||
enabled: boolean;
|
||||
rolloutPercent: number;
|
||||
allowUserIds: string[];
|
||||
allowUserTags: string[];
|
||||
denyUserIds: string[];
|
||||
description: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminFeatureGateConfigResponse {
|
||||
gates: AdminFeatureGateConfigPayload[];
|
||||
}
|
||||
|
||||
export type AdminUpsertFeatureGateConfigRequest = Omit<
|
||||
AdminFeatureGateConfigPayload,
|
||||
'updatedAt'
|
||||
>;
|
||||
|
||||
/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */
|
||||
export interface AdminCreationEntryConfigResponse {
|
||||
entries: AdminCreationEntryTypeConfigPayload[];
|
||||
|
||||
@@ -26,6 +26,7 @@ import {AdminLoginPage} from '../pages/AdminLoginPage';
|
||||
import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage';
|
||||
import {AdminEditorAssetQueryPage} from '../pages/AdminEditorAssetQueryPage';
|
||||
import {AdminEditorShowcaseReviewPage} from '../pages/AdminEditorShowcaseReviewPage';
|
||||
import {AdminGrayReleaseConfigPage} from '../pages/AdminGrayReleaseConfigPage';
|
||||
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
|
||||
import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage';
|
||||
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
|
||||
@@ -186,6 +187,12 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'gray-release' ? (
|
||||
<AdminGrayReleaseConfigPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'redeem' ? (
|
||||
<AdminRedeemCodePage
|
||||
token={token}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LogOut,
|
||||
Megaphone,
|
||||
Eye,
|
||||
GitBranch,
|
||||
Images,
|
||||
Star,
|
||||
WalletCards,
|
||||
@@ -38,6 +39,7 @@ const routeIcons = {
|
||||
tables: Database,
|
||||
debug: Bug,
|
||||
tracking: Table2,
|
||||
'gray-release': GitBranch,
|
||||
redeem: TicketPercent,
|
||||
invite: TicketCheck,
|
||||
'profile-wallet': WalletCards,
|
||||
|
||||
@@ -40,6 +40,16 @@ test('后台模型定价路由可通过导航和 hash 访问', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('后台灰度发布路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'gray-release',
|
||||
label: '灰度发布',
|
||||
hash: '#gray-release',
|
||||
});
|
||||
expect(resolveAdminRoute('#gray-release')).toBe('gray-release');
|
||||
expect(routeHash('gray-release')).toBe('#gray-release');
|
||||
});
|
||||
|
||||
test('后台素材查询路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'editor-assets',
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AdminRouteId =
|
||||
| 'tables'
|
||||
| 'debug'
|
||||
| 'tracking'
|
||||
| 'gray-release'
|
||||
| 'redeem'
|
||||
| 'invite'
|
||||
| 'profile-wallet'
|
||||
@@ -30,6 +31,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'tables', label: '表查询', hash: '#tables'},
|
||||
{id: 'debug', label: 'API 调试', hash: '#debug'},
|
||||
{id: 'tracking', label: '埋点数据', hash: '#tracking'},
|
||||
{id: 'gray-release', label: '灰度发布', hash: '#gray-release'},
|
||||
{id: 'redeem', label: '兑换码', hash: '#redeem'},
|
||||
{id: 'invite', label: '邀请码', hash: '#invite'},
|
||||
{id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet'},
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminCreationEntryConfig,
|
||||
getAdminFeatureGateConfig,
|
||||
upsertAdminFeatureGateConfig,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminCreationEntryConfigResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminGrayReleaseConfigPage } from './AdminGrayReleaseConfigPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
getAdminCreationEntryConfig: vi.fn(),
|
||||
getAdminFeatureGateConfig: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
upsertAdminFeatureGateConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
const configResponse: AdminFeatureGateConfigResponse = {
|
||||
gates: [
|
||||
{
|
||||
gateKey: 'editor.new-toolbar',
|
||||
enabled: true,
|
||||
rolloutPercent: 25,
|
||||
allowUserIds: ['user-1'],
|
||||
allowUserTags: ['beta'],
|
||||
denyUserIds: ['blocked-1'],
|
||||
description: '新版编辑器工具条',
|
||||
updatedAt: '2026-07-07T01:00:00Z',
|
||||
},
|
||||
{
|
||||
gateKey: 'image.generator.v2',
|
||||
enabled: false,
|
||||
rolloutPercent: 5,
|
||||
allowUserIds: ['artist-1', 'artist-2'],
|
||||
allowUserTags: ['internal', 'trial'],
|
||||
denyUserIds: [],
|
||||
description: '图片生成链路',
|
||||
updatedAt: '2026-07-07T02:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creationEntryResponse: AdminCreationEntryConfigResponse = {
|
||||
entries: [
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
subtitle: '',
|
||||
badge: '',
|
||||
imageSrc: '',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
categoryId: 'default',
|
||||
categoryLabel: '默认',
|
||||
categorySortOrder: 0,
|
||||
updatedAtMicros: 0,
|
||||
unifiedCreationSpec: null,
|
||||
},
|
||||
{
|
||||
id: 'match3d',
|
||||
title: '3D 消除',
|
||||
subtitle: '',
|
||||
badge: '',
|
||||
imageSrc: '',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 20,
|
||||
categoryId: 'default',
|
||||
categoryLabel: '默认',
|
||||
categorySortOrder: 0,
|
||||
updatedAtMicros: 0,
|
||||
unifiedCreationSpec: null,
|
||||
},
|
||||
],
|
||||
eventBanners: [],
|
||||
publicWorkInteractions: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(
|
||||
creationEntryResponse,
|
||||
);
|
||||
vi.mocked(getAdminFeatureGateConfig).mockResolvedValue(configResponse);
|
||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValue(configResponse);
|
||||
});
|
||||
|
||||
test('灰度发布页加载并展示 gate 列表', async () => {
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'image.generator.v2' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('25%')).toBeTruthy();
|
||||
expect(getAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token');
|
||||
expect(getAdminCreationEntryConfig).toHaveBeenCalledWith('admin-token');
|
||||
});
|
||||
|
||||
test('灰度发布页可选择已有 gate 编辑', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'image.generator.v2' }),
|
||||
);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'image.generator.v2',
|
||||
);
|
||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||
false,
|
||||
);
|
||||
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
||||
'5',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('允许用户 ID') as HTMLTextAreaElement).value,
|
||||
).toBe('artist-1\nartist-2');
|
||||
expect(
|
||||
(screen.getByLabelText('允许用户标签') as HTMLTextAreaElement).value,
|
||||
).toBe('internal\ntrial');
|
||||
expect(
|
||||
(screen.getByLabelText('拒绝用户 ID') as HTMLTextAreaElement).value,
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('灰度发布页选择新 target 时重置旧 gate 规则', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' }),
|
||||
);
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
||||
'creation-entry',
|
||||
]);
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 目标'), ['match3d']);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'creation-entry:match3d',
|
||||
);
|
||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
||||
false,
|
||||
);
|
||||
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
||||
'0',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('允许用户 ID') as HTMLTextAreaElement).value,
|
||||
).toBe('');
|
||||
expect(
|
||||
(screen.getByLabelText('允许用户标签') as HTMLTextAreaElement).value,
|
||||
).toBe('');
|
||||
expect(
|
||||
(screen.getByLabelText('拒绝用户 ID') as HTMLTextAreaElement).value,
|
||||
).toBe('');
|
||||
expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe(
|
||||
'3D 消除创作入口灰度',
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页可通过创作入口生成 Gate Key', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
||||
'creation-entry',
|
||||
]);
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 目标'), ['puzzle']);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'creation-entry:puzzle',
|
||||
);
|
||||
expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe(
|
||||
'拼图创作入口灰度',
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页可通过功能入口生成画布 Agent Gate Key', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
|
||||
'image-editor',
|
||||
]);
|
||||
|
||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
||||
'image-editor:agent-sidebar',
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
|
||||
).toBe('agent-sidebar');
|
||||
expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe(
|
||||
'画布 Agent 入口灰度',
|
||||
);
|
||||
});
|
||||
|
||||
test('灰度发布页保存时转换数组和百分比', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
||||
gates: [
|
||||
...configResponse.gates,
|
||||
{
|
||||
gateKey: 'homepage.feed-redesign',
|
||||
enabled: true,
|
||||
rolloutPercent: 42,
|
||||
allowUserIds: ['user-a', 'user-b'],
|
||||
allowUserTags: ['beta', 'staff'],
|
||||
denyUserIds: ['blocked-a'],
|
||||
description: '首页信息流',
|
||||
updatedAt: '2026-07-07T03:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
||||
fireEvent.change(screen.getByLabelText('Gate Key'), {
|
||||
target: { value: 'homepage.feed-redesign' },
|
||||
});
|
||||
await user.click(screen.getByLabelText('启用'));
|
||||
fireEvent.change(screen.getByLabelText('灰度比例'), {
|
||||
target: { value: '42' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('允许用户 ID'), {
|
||||
target: { value: ' user-a \n\n user-b ' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('允许用户标签'), {
|
||||
target: { value: ' beta \n staff ' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('拒绝用户 ID'), {
|
||||
target: { value: ' blocked-a \n ' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('描述'), {
|
||||
target: { value: ' 首页信息流 ' },
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '保存配置' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token', {
|
||||
gateKey: 'homepage.feed-redesign',
|
||||
enabled: true,
|
||||
rolloutPercent: 42,
|
||||
allowUserIds: ['user-a', 'user-b'],
|
||||
allowUserTags: ['beta', 'staff'],
|
||||
denyUserIds: ['blocked-a'],
|
||||
description: '首页信息流',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('灰度发布页无 token 时不请求配置', () => {
|
||||
render(<AdminGrayReleaseConfigPage token="" onUnauthorized={vi.fn()} />);
|
||||
|
||||
expect(getAdminCreationEntryConfig).not.toHaveBeenCalled();
|
||||
expect(getAdminFeatureGateConfig).not.toHaveBeenCalled();
|
||||
expect(upsertAdminFeatureGateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -516,6 +516,12 @@ button:disabled {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-gate-key-selectors {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 0.42fr) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr)) auto;
|
||||
@@ -1515,6 +1521,10 @@ button:disabled {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.admin-gate-key-selectors {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-bottom-nav {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
|
||||
@@ -3862,3 +3862,13 @@
|
||||
- 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate_image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate_image`,只有实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。
|
||||
- 影响范围:`server-rs/crates/platform-agent`、`server-rs/crates/api-server/src/config.rs`、`src/services/llmClient.ts`、`.env.example`、`deploy/env/api-server.env.example`、`scripts/test-ve-llm.mjs`。
|
||||
- 验证方式:`npm run test -- src/services/llmClient.test.ts`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml from_env_reads_non_public_models_and_urls app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings llm_chat_completions editor_agent_llm_request_uses_vector_engine_chat_model`、`cargo test -p platform-agent --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-07-07 功能灰度以后端事实源判定
|
||||
|
||||
- 背景:平台需要把新功能先开放给部分用户,首个接入点是创作入口;灰度规则不能泄露用户标签或完整受众配置给普通前端。
|
||||
- 决策:新增 SpacetimeDB `feature_gate_config` 表作为通用功能灰度事实源,后台通过 `/admin/api/feature-gates` 配置 gate。创作入口使用 `creation-entry:<id>` gate key 约定;`api-server` 在 `/api/creation-entry/config` 和入口路由熔断中按可选登录用户、用户标签、用户 ID 黑白名单和稳定百分比做判定,只返回当前用户过滤后的入口状态。
|
||||
- 后台:灰度页的 Gate Key 选择器按 `prefix:suffix` 两段式配置;后续新增固定功能灰度 key 时,必须同步维护后台下拉框的固定目标配置,避免运营手输 key。
|
||||
- 语义:未配置 gate 或 `enabled=false` 不限制访问;启用后黑名单用户 ID 优先,其次用户 ID 白名单、用户标签白名单、稳定百分比。`enabled=true` 且 `rolloutPercent=0` 是有意的 kill switch;后台选择尚不存在的新 target 时必须重置启用状态、比例和黑白名单,不能隐式继承上一条 gate 的规则。前端只消费过滤后的 `visible/open`,不承接灰度规则真相。
|
||||
- 性能:`api-server` 只在当前判定涉及的已启用 gate 配置了用户标签白名单时读取用户标签;不因无关 gate 或纯用户 ID / 百分比灰度触发额外标签读取。
|
||||
- 影响范围:`feature_gate_config`、`spacetime-client` runtime facade、`api-server` 创作入口配置与路由熔断、`apps/admin-web` 灰度发布页。
|
||||
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-runtime --manifest-path server-rs/Cargo.toml feature_gate`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml creation_entry_feature_gate`、`npm run admin-web:typecheck`、后台灰度页 Vitest、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
@@ -2853,3 +2853,19 @@
|
||||
- 处理:租约持有 `Arc<SpacetimeConnectionPool>` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。
|
||||
- 验证:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(`dropped_lease_releases_slot_and_permit`、`acquire_times_out_at_pool_acquire_when_pool_is_busy`)。
|
||||
- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`。
|
||||
|
||||
## 后台灰度配置不能从 SpacetimeDB 本地表缓存读取
|
||||
|
||||
- 现象:后台灰度页保存 `image-editor:agent-sidebar` 后当前响应能看到 gate,但刷新后台页列表变空;前台画布 Agent 入口仍显示,0% 灰度没有生效。
|
||||
- 原因:`feature_gate_config` 是后台私有事实表,`spacetime-client` 如果优先读 SDK 本地订阅表缓存,可能得到空表并覆盖 procedure 返回后的正确缓存。灰度语义里“未配置 gate”表示不限制访问,所以空列表会让功能继续开放。
|
||||
- 处理:灰度配置读取必须走 `get_feature_gate_config` procedure 的事务快照,成功后再更新进程缓存;缓存只作为 procedure 暂时失败后的兜底。不要订阅或读取 `feature_gate_config` 本地表来判断后台配置。
|
||||
- 验证:`RUSTC_WRAPPER= cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`;`RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml frontend_runtime_config_denies_anonymous_agent_sidebar_when_gate_enabled`;`RUSTC_WRAPPER= cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent_api_returns_service_unavailable_when_sidebar_gate_denies_user`。
|
||||
- 关联:`server-rs/crates/spacetime-client/src/runtime.rs`、`server-rs/crates/spacetime-client/src/lib.rs`、`server-rs/crates/api-server/src/frontend_runtime_config.rs`、`server-rs/crates/api-server/src/editor_agent.rs`。
|
||||
|
||||
## 后台灰度新 target 不能继承旧规则
|
||||
|
||||
- 现象:管理员先点开一条已有 gate,再从两段式下拉框选择一个尚不存在的新 target,保存后新 gate 可能带着上一条 gate 的启用状态、灰度比例和黑白名单。
|
||||
- 原因:新 target 分支如果只更新 gate key,会复用当前 React 表单状态;这些字段对运营不可见地跨 target 泄漏。
|
||||
- 处理:`applyGateTarget` 进入不存在的新 target 时必须重置为新建态:`enabled=false`、`rolloutPercent=0`、allow / deny 列表为空,并使用 target 默认描述。只有显式点已有 gate 才 `fillForm` 复制服务端规则。
|
||||
- 验证:`npm run test -- apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx`。
|
||||
- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx`、`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx`。
|
||||
|
||||
@@ -399,6 +399,14 @@ npm run check:server-rs-ddd
|
||||
- 字段:`id`、`title`、`subtitle`、`badge`、`image_src`、`visible`、`open`、`sort_order`、`updated_at`、`category_id`、`category_label`、`category_sort_order`、`unified_creation_spec_json`。
|
||||
- 迁移兼容:旧迁移包缺少入口分类字段或统一创作契约字段时,由 `migration.rs` 写入 `None` / `0` / `None` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费,统一创作契约由 `module-runtime` 解析为 `creationTypes[].unifiedCreationSpec`,为空时按 `shared-contracts` 中当前支持的统一创作默认 spec 回退。`unifiedCreationSpec.title` 是统一创作页表头契约内容,读取和保存时不按入口 `title` 自动覆盖。
|
||||
|
||||
### `feature_gate_config`
|
||||
|
||||
- Rust 结构体:`FeatureGateConfig`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/feature_gate_config.rs`
|
||||
- 字段:`gate_key`、`enabled`、`rollout_percent`、`allow_user_ids`、`allow_user_tags`、`deny_user_ids`、`description`、`updated_at`。
|
||||
- 用途:通用功能灰度事实源。当前创作入口使用 `creation-entry:<id>` 约定关联入口 ID;`api-server` 按当前可选登录用户、用户标签和稳定百分比判定后,只把过滤后的入口配置返回普通前端,不下发灰度规则或用户标签。
|
||||
- 迁移兼容:新增表不改已有入口表字段;未配置 gate 或 `enabled=false` 时不限制功能,黑名单用户 ID 优先于白名单和百分比命中。
|
||||
|
||||
### `custom_world_agent_message`
|
||||
|
||||
- Rust 结构体:`CustomWorldAgentMessage`
|
||||
|
||||
@@ -236,7 +236,9 @@ npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.servi
|
||||
|
||||
主站前端运行时配置由 `api-server` 下发;画板右侧 Agent 入口使用
|
||||
`GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR=false` 默认关闭,需要开启时只改生产
|
||||
api-server 环境变量并重启 `api-server`,不再通过 `VITE_*` 构建期变量控制。
|
||||
api-server 环境变量并重启 `api-server`,不再通过 `VITE_*` 构建期变量控制。若后台再配置
|
||||
`image-editor:agent-sidebar` 灰度 gate,`enabled=true` 且 `rolloutPercent=0` 表示有意关闭该入口;
|
||||
只有当前判定涉及的已启用 gate 配置了用户标签白名单时,`api-server` 才读取用户标签。
|
||||
|
||||
```env
|
||||
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb
|
||||
|
||||
@@ -31,14 +31,15 @@ use shared_contracts::admin::{
|
||||
AdminEditorShowcaseAssetPayload, AdminEditorShowcaseAssetResponse,
|
||||
AdminEditorShowcaseCampaignPayload, AdminEditorShowcaseCampaignResponse,
|
||||
AdminEditorShowcaseDisplayRequest, AdminEditorShowcaseListQuery,
|
||||
AdminEditorShowcaseListResponse, AdminEditorShowcaseReviewRequest, AdminLoginRequest,
|
||||
AdminEditorShowcaseListResponse, AdminEditorShowcaseReviewRequest,
|
||||
AdminFeatureGateConfigPayload, AdminFeatureGateConfigResponse, AdminLoginRequest,
|
||||
AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload,
|
||||
AdminSessionPayload, AdminTrackingEventEntryPayload, AdminTrackingEventKeyListResponse,
|
||||
AdminTrackingEventKeyPayload, AdminTrackingEventListQuery, AdminTrackingEventListResponse,
|
||||
AdminUpdateWorkVisibilityRequest, AdminUpdateWorkVisibilityResponse,
|
||||
AdminUpsertCreationEntryEventBannersRequest, AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertPublicWorkInteractionConfigRequest,
|
||||
AdminWorkVisibilityListResponse,
|
||||
AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertFeatureGateConfigRequest,
|
||||
AdminUpsertPublicWorkInteractionConfigRequest, AdminWorkVisibilityListResponse,
|
||||
};
|
||||
use shared_contracts::assets::{CreateDirectUploadTicketRequest, DirectUploadObjectAccess};
|
||||
use shared_contracts::creation_entry_config::{
|
||||
@@ -350,6 +351,40 @@ pub async fn admin_upsert_public_work_interaction_config(
|
||||
))
|
||||
}
|
||||
|
||||
/// 后台读取通用灰度配置。
|
||||
pub async fn admin_get_feature_gate_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let gates = state
|
||||
.get_feature_gate_config()
|
||||
.await
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_admin_feature_gate_config_response(gates),
|
||||
))
|
||||
}
|
||||
|
||||
/// 后台保存单个灰度 gate 配置。
|
||||
pub async fn admin_upsert_feature_gate_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminUpsertFeatureGateConfigRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let input = validate_admin_feature_gate_config(payload)?;
|
||||
let gates = state
|
||||
.upsert_feature_gate_config(input)
|
||||
.await
|
||||
.map_err(map_admin_spacetime_error)?;
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_admin_feature_gate_config_response(gates),
|
||||
))
|
||||
}
|
||||
|
||||
/// 后台读取画布生成模型定价配置。
|
||||
pub async fn admin_get_editor_generation_pricing(
|
||||
State(state): State<AppState>,
|
||||
@@ -709,6 +744,33 @@ fn map_admin_creation_entry_type_config(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_admin_feature_gate_config_response(
|
||||
mut gates: Vec<module_runtime::FeatureGateConfigSnapshot>,
|
||||
) -> AdminFeatureGateConfigResponse {
|
||||
gates.sort_by(|left, right| left.gate_key.cmp(&right.gate_key));
|
||||
AdminFeatureGateConfigResponse {
|
||||
gates: gates
|
||||
.into_iter()
|
||||
.map(map_admin_feature_gate_config)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_admin_feature_gate_config(
|
||||
gate: module_runtime::FeatureGateConfigSnapshot,
|
||||
) -> AdminFeatureGateConfigPayload {
|
||||
AdminFeatureGateConfigPayload {
|
||||
gate_key: gate.gate_key,
|
||||
enabled: gate.enabled,
|
||||
rollout_percent: gate.rollout_percent,
|
||||
allow_user_ids: gate.allow_user_ids,
|
||||
allow_user_tags: gate.allow_user_tags,
|
||||
deny_user_ids: gate.deny_user_ids,
|
||||
description: gate.description,
|
||||
updated_at: module_runtime::format_utc_micros(gate.updated_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
async fn refund_approved_editor_showcase_asset_if_needed(
|
||||
state: &AppState,
|
||||
record: EditorShowcaseAssetRecord,
|
||||
@@ -1012,6 +1074,23 @@ fn validate_admin_creation_entry_config(
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_admin_feature_gate_config(
|
||||
payload: AdminUpsertFeatureGateConfigRequest,
|
||||
) -> Result<module_runtime::FeatureGateConfigAdminUpsertInput, AppError> {
|
||||
module_runtime::normalize_feature_gate_admin_upsert_input(
|
||||
module_runtime::FeatureGateConfigAdminUpsertInput {
|
||||
gate_key: payload.gate_key,
|
||||
enabled: payload.enabled,
|
||||
rollout_percent: payload.rollout_percent,
|
||||
allow_user_ids: payload.allow_user_ids,
|
||||
allow_user_tags: payload.allow_user_tags,
|
||||
deny_user_ids: payload.deny_user_ids,
|
||||
description: payload.description,
|
||||
},
|
||||
)
|
||||
.map_err(|error| AppError::from_status(StatusCode::BAD_REQUEST).with_message(error))
|
||||
}
|
||||
|
||||
fn validate_admin_work_visibility(
|
||||
payload: AdminUpdateWorkVisibilityRequest,
|
||||
) -> Result<(String, String, bool), AppError> {
|
||||
|
||||
@@ -564,6 +564,104 @@ mod tests {
|
||||
assert_eq!(payload["imageEditorAgentSidebarEnabled"], Value::Bool(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn frontend_runtime_config_denies_anonymous_agent_sidebar_when_gate_enabled() {
|
||||
let config = AppConfig {
|
||||
image_editor_agent_sidebar_enabled: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
state.set_test_feature_gate_config(vec![test_feature_gate(
|
||||
module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY,
|
||||
)]);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/runtime/frontend-config")
|
||||
.body(Body::empty())
|
||||
.expect("frontend config request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("frontend config request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload = read_json_response(response).await;
|
||||
assert_eq!(
|
||||
payload["imageEditorAgentSidebarEnabled"],
|
||||
Value::Bool(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn frontend_runtime_config_allows_whitelisted_agent_sidebar_user() {
|
||||
let config = AppConfig {
|
||||
image_editor_agent_sidebar_enabled: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let user = seed_phone_user_with_password(&state, "13800138192", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &user, "sess_agent_sidebar_gate");
|
||||
let mut gate = test_feature_gate(module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY);
|
||||
gate.allow_user_ids = vec![user.id.clone()];
|
||||
state.set_test_feature_gate_config(vec![gate]);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/runtime/frontend-config")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.expect("frontend config request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("frontend config request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload = read_json_response(response).await;
|
||||
assert_eq!(payload["imageEditorAgentSidebarEnabled"], Value::Bool(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_agent_api_returns_service_unavailable_when_sidebar_gate_denies_user() {
|
||||
let config = AppConfig {
|
||||
image_editor_agent_sidebar_enabled: true,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let user = seed_phone_user_with_password(&state, "13800138193", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &user, "sess_agent_sidebar_denied");
|
||||
state.set_test_feature_gate_config(vec![test_feature_gate(
|
||||
module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY,
|
||||
)]);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/editor/projects/project-1/agent-conversations")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = read_json_response(response).await;
|
||||
assert_eq!(
|
||||
body["error"]["details"]["reason"],
|
||||
"image_editor_agent_sidebar_disabled"
|
||||
);
|
||||
assert_eq!(
|
||||
body["error"]["details"]["gateKey"],
|
||||
module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spacetime_unavailable_router_returns_service_unavailable_for_requests() {
|
||||
let app =
|
||||
@@ -623,6 +721,57 @@ mod tests {
|
||||
assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creation_entry_feature_gate_denies_anonymous_user() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
state.set_test_feature_gate_config(vec![test_feature_gate("creation-entry:puzzle")]);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/puzzle/agent/sessions")
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = read_json_response(response).await;
|
||||
assert_eq!(
|
||||
body["error"]["details"]["reason"],
|
||||
"creation_entry_disabled"
|
||||
);
|
||||
assert_eq!(body["error"]["details"]["creationTypeId"], "puzzle");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creation_entry_feature_gate_allows_whitelisted_user() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let user = seed_phone_user_with_password(&state, "13800138191", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &user, "sess_creation_entry_gate");
|
||||
let mut gate = test_feature_gate("creation-entry:puzzle");
|
||||
gate.allow_user_ids = vec![user.id.clone()];
|
||||
state.set_test_feature_gate_config(vec![gate]);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/runtime/puzzle/agent/sessions")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_ne!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_creation_entry_does_not_block_published_runtime_routes() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
@@ -648,6 +797,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn test_feature_gate(gate_key: &str) -> module_runtime::FeatureGateConfigSnapshot {
|
||||
module_runtime::FeatureGateConfigSnapshot {
|
||||
gate_key: gate_key.to_string(),
|
||||
enabled: true,
|
||||
rollout_percent: 0,
|
||||
allow_user_ids: vec![],
|
||||
allow_user_tags: vec![],
|
||||
deny_user_ids: vec![],
|
||||
description: String::new(),
|
||||
updated_at_micros: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_public_work_like_returns_service_unavailable() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
|
||||
@@ -206,6 +206,15 @@ async fn authenticate_runtime_principal(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn optional_access_token_from_headers(
|
||||
state: &AppState,
|
||||
path: String,
|
||||
headers: HeaderMap,
|
||||
request_id: String,
|
||||
) -> Result<Option<AuthenticatedAccessToken>, AppError> {
|
||||
authenticate_request(state, path, headers, request_id).await
|
||||
}
|
||||
|
||||
async fn authenticate_request(
|
||||
state: &AppState,
|
||||
path: String,
|
||||
|
||||
@@ -2,7 +2,7 @@ use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
extract::{Extension, State},
|
||||
http::{Request, StatusCode},
|
||||
http::{HeaderMap, Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
@@ -36,16 +36,31 @@ impl PublicWorkInteractionAction {
|
||||
pub async fn get_creation_entry_config_handler(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let config = state.get_creation_entry_config().await.map_err(|error| {
|
||||
creation_entry_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let authenticated = crate::auth::optional_access_token_from_headers(
|
||||
&state,
|
||||
"/api/creation-entry/config".to_string(),
|
||||
headers,
|
||||
request_context.request_id().to_string(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| creation_entry_error_response(&request_context, error))?;
|
||||
let user_id = authenticated
|
||||
.as_ref()
|
||||
.map(|authenticated| authenticated.claims().user_id());
|
||||
let config = state
|
||||
.get_creation_entry_config_for_user(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
creation_entry_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(Some(&request_context), config))
|
||||
}
|
||||
@@ -60,7 +75,29 @@ pub async fn require_creation_entry_route_enabled(
|
||||
let route_id = resolve_creation_entry_route_id(path);
|
||||
if route_id.is_some() {
|
||||
let route_id = route_id.expect("route id should exist");
|
||||
match state.is_creation_entry_route_enabled(route_id).await {
|
||||
let request_context = request.extensions().get::<RequestContext>().cloned();
|
||||
let request_id = request_context
|
||||
.as_ref()
|
||||
.map(|context| context.request_id().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let authenticated = match crate::auth::optional_access_token_from_headers(
|
||||
&state,
|
||||
path.to_string(),
|
||||
request.headers().clone(),
|
||||
request_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(authenticated) => authenticated,
|
||||
Err(error) => return error.into(),
|
||||
};
|
||||
let user_id = authenticated
|
||||
.as_ref()
|
||||
.map(|authenticated| authenticated.claims().user_id());
|
||||
match state
|
||||
.is_creation_entry_route_enabled_for_user(route_id, user_id)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
|
||||
@@ -87,6 +87,7 @@ pub async fn list_editor_agent_conversations(
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
|
||||
ensure_editor_project_access(&state, project_id.as_str(), owner_user_id.as_str()).await?;
|
||||
let conversations = state
|
||||
.spacetime_client()
|
||||
@@ -111,6 +112,7 @@ pub async fn create_editor_agent_conversation(
|
||||
Json(payload): Json<CreateEditorAgentConversationRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
|
||||
ensure_editor_project_access(&state, project_id.as_str(), owner_user_id.as_str()).await?;
|
||||
|
||||
let conversation_id = build_prefixed_uuid_id(EDITOR_AGENT_CONVERSATION_ID_PREFIX);
|
||||
@@ -165,6 +167,7 @@ pub async fn get_editor_agent_conversation(
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
|
||||
let conversation = state
|
||||
.spacetime_client()
|
||||
.get_editor_agent_conversation(conversation_id, owner_user_id)
|
||||
@@ -187,6 +190,7 @@ pub async fn delete_editor_agent_conversation(
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
|
||||
let conversation = state
|
||||
.spacetime_client()
|
||||
.delete_editor_agent_conversation(EditorAgentConversationDeleteRecordInput {
|
||||
@@ -214,6 +218,7 @@ pub async fn stream_editor_agent_message(
|
||||
Json(payload): Json<StreamEditorAgentMessageRequest>,
|
||||
) -> Result<Response, AppError> {
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
|
||||
let client_message_id = normalize_required_string(payload.client_message_id.as_str())
|
||||
.ok_or_else(|| editor_agent_bad_request("clientMessageId is required"))?;
|
||||
let attachment_reference_ids = payload
|
||||
@@ -2152,6 +2157,35 @@ fn now_rfc3339() -> String {
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||||
}
|
||||
|
||||
async fn require_editor_agent_sidebar_enabled(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
match state
|
||||
.is_image_editor_agent_sidebar_enabled_for_user(Some(owner_user_id))
|
||||
.await
|
||||
{
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => Err(editor_agent_sidebar_unavailable()),
|
||||
Err(error) => Err(AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("读取画布 Agent 灰度配置失败")
|
||||
.with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))),
|
||||
}
|
||||
}
|
||||
|
||||
fn editor_agent_sidebar_unavailable() -> AppError {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("画布 Agent 暂不可用")
|
||||
.with_details(json!({
|
||||
"provider": "editor-agent",
|
||||
"reason": "image_editor_agent_sidebar_disabled",
|
||||
"gateKey": module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY,
|
||||
}))
|
||||
}
|
||||
|
||||
fn editor_agent_bad_request(message: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "editor-agent",
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{api_response::json_success_body, request_context::RequestContext, state::AppState};
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::optional_access_token_from_headers,
|
||||
http_error::AppError, request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -15,11 +20,34 @@ pub struct FrontendRuntimeConfigResponse {
|
||||
pub async fn get_frontend_runtime_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Json<serde_json::Value> {
|
||||
json_success_body(
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let authenticated = optional_access_token_from_headers(
|
||||
&state,
|
||||
"/api/runtime/frontend-config".to_string(),
|
||||
headers,
|
||||
request_context.request_id().to_string(),
|
||||
)
|
||||
.await?;
|
||||
let user_id = authenticated
|
||||
.as_ref()
|
||||
.map(|authenticated| authenticated.claims().user_id());
|
||||
let image_editor_agent_sidebar_enabled = state
|
||||
.is_image_editor_agent_sidebar_enabled_for_user(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("读取前端运行时配置失败")
|
||||
.with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
FrontendRuntimeConfigResponse {
|
||||
image_editor_agent_sidebar_enabled: state.config.image_editor_agent_sidebar_enabled,
|
||||
image_editor_agent_sidebar_enabled,
|
||||
},
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
@@ -5,17 +5,18 @@ use axum::{
|
||||
|
||||
use crate::{
|
||||
admin::{
|
||||
admin_dashboard, admin_debug_http, admin_get_creation_entry_config,
|
||||
admin_create_editor_showcase_campaign_image_upload_ticket,
|
||||
admin_get_editor_generation_pricing, admin_get_editor_showcase_campaign,
|
||||
admin_list_database_table_rows, admin_list_database_tables,
|
||||
admin_list_editor_assets, admin_list_editor_showcase_assets, admin_list_tracking_event_keys,
|
||||
admin_create_editor_showcase_campaign_image_upload_ticket, admin_dashboard,
|
||||
admin_debug_http, admin_get_creation_entry_config, admin_get_editor_generation_pricing,
|
||||
admin_get_editor_showcase_campaign, admin_get_feature_gate_config,
|
||||
admin_list_database_table_rows, admin_list_database_tables, admin_list_editor_assets,
|
||||
admin_list_editor_showcase_assets, admin_list_tracking_event_keys,
|
||||
admin_list_tracking_events, admin_list_work_visibility, admin_login, admin_me,
|
||||
admin_overview, admin_review_editor_showcase_asset,
|
||||
admin_update_editor_showcase_asset_display, admin_update_work_visibility,
|
||||
admin_upsert_creation_entry_config, admin_upsert_creation_entry_event_banners_config,
|
||||
admin_upsert_editor_generation_pricing, admin_upsert_editor_showcase_campaign,
|
||||
admin_upsert_public_work_interaction_config, require_admin_auth,
|
||||
admin_upsert_feature_gate_config, admin_upsert_public_work_interaction_config,
|
||||
require_admin_auth,
|
||||
},
|
||||
runtime_profile::{
|
||||
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
|
||||
@@ -108,6 +109,15 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
middleware::from_fn_with_state(state.clone(), require_admin_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/feature-gates",
|
||||
get(admin_get_feature_gate_config)
|
||||
.put(admin_upsert_feature_gate_config)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/editor-generation-pricing",
|
||||
get(admin_get_editor_generation_pricing)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
error::Error,
|
||||
fmt,
|
||||
sync::{
|
||||
@@ -247,6 +247,8 @@ pub struct AppStateInner {
|
||||
#[cfg(test)]
|
||||
test_creation_entry_config: Arc<Mutex<Option<CreationEntryConfigResponse>>>,
|
||||
#[cfg(test)]
|
||||
test_feature_gate_config: Arc<Mutex<Option<Vec<module_runtime::FeatureGateConfigSnapshot>>>>,
|
||||
#[cfg(test)]
|
||||
test_spacetime_health: Arc<Mutex<Option<SpacetimeClientHealthSnapshot>>>,
|
||||
oss_client: Option<OssClient>,
|
||||
#[cfg_attr(test, allow(dead_code))]
|
||||
@@ -433,6 +435,8 @@ impl AppState {
|
||||
crate::creation_entry_config::test_creation_entry_config_response(),
|
||||
))),
|
||||
#[cfg(test)]
|
||||
test_feature_gate_config: Arc::new(Mutex::new(Some(vec![]))),
|
||||
#[cfg(test)]
|
||||
test_spacetime_health: Arc::new(Mutex::new(Some(
|
||||
SpacetimeClientHealthSnapshot::healthy_for_test(),
|
||||
))),
|
||||
@@ -646,6 +650,122 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_feature_gate_config(
|
||||
&self,
|
||||
) -> Result<Vec<module_runtime::FeatureGateConfigSnapshot>, SpacetimeClientError> {
|
||||
match self.spacetime_client.get_feature_gate_config().await {
|
||||
Ok(config) => {
|
||||
#[cfg(test)]
|
||||
self.cache_test_feature_gate_config(config.clone());
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
Err(error) if is_missing_feature_gate_config_procedure(&error) => {
|
||||
warn!(
|
||||
error = %error,
|
||||
"本地 SpacetimeDB 缺少灰度配置 procedure,使用空灰度配置兜底"
|
||||
);
|
||||
Ok(vec![])
|
||||
}
|
||||
#[cfg(test)]
|
||||
Err(_) => Ok(self.read_test_feature_gate_config()),
|
||||
#[cfg(not(test))]
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn upsert_feature_gate_config(
|
||||
&self,
|
||||
input: module_runtime::FeatureGateConfigAdminUpsertInput,
|
||||
) -> Result<Vec<module_runtime::FeatureGateConfigSnapshot>, SpacetimeClientError> {
|
||||
#[cfg(test)]
|
||||
let test_input = input.clone();
|
||||
match self
|
||||
.spacetime_client
|
||||
.upsert_feature_gate_config(input)
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
#[cfg(test)]
|
||||
self.cache_test_feature_gate_config(config.clone());
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(test)]
|
||||
Err(_) => {
|
||||
let normalized =
|
||||
module_runtime::normalize_feature_gate_admin_upsert_input(test_input)
|
||||
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?;
|
||||
let mut config = self.read_test_feature_gate_config();
|
||||
let now_micros = crate::editor_project::current_utc_micros();
|
||||
let record = module_runtime::FeatureGateConfigSnapshot {
|
||||
gate_key: normalized.gate_key.clone(),
|
||||
enabled: normalized.enabled,
|
||||
rollout_percent: normalized.rollout_percent,
|
||||
allow_user_ids: normalized.allow_user_ids,
|
||||
allow_user_tags: normalized.allow_user_tags,
|
||||
deny_user_ids: normalized.deny_user_ids,
|
||||
description: normalized.description,
|
||||
updated_at_micros: now_micros,
|
||||
};
|
||||
if let Some(existing) = config
|
||||
.iter_mut()
|
||||
.find(|item| item.gate_key == normalized.gate_key)
|
||||
{
|
||||
*existing = record;
|
||||
} else {
|
||||
config.push(record);
|
||||
}
|
||||
config.sort_by(|left, right| left.gate_key.cmp(&right.gate_key));
|
||||
self.cache_test_feature_gate_config(config.clone());
|
||||
Ok(config)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_creation_entry_config_for_user(
|
||||
&self,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<CreationEntryConfigResponse, SpacetimeClientError> {
|
||||
let config = self.get_creation_entry_config().await?;
|
||||
let gates = self.get_feature_gate_config().await?;
|
||||
let user_context = self
|
||||
.feature_gate_user_context(
|
||||
user_id,
|
||||
creation_entry_feature_gates_require_user_tags(&config, &gates),
|
||||
)
|
||||
.await;
|
||||
Ok(
|
||||
module_runtime::apply_feature_gates_to_creation_entry_config(
|
||||
config,
|
||||
&gates,
|
||||
&user_context,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn is_image_editor_agent_sidebar_enabled_for_user(
|
||||
&self,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<bool, SpacetimeClientError> {
|
||||
if !self.config.image_editor_agent_sidebar_enabled {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let gates = self.get_feature_gate_config().await?;
|
||||
let gate = gates
|
||||
.iter()
|
||||
.find(|item| item.gate_key == module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY);
|
||||
let user_context = self
|
||||
.feature_gate_user_context(
|
||||
user_id,
|
||||
gate.map(feature_gate_requires_user_tags).unwrap_or(false),
|
||||
)
|
||||
.await;
|
||||
Ok(module_runtime::is_feature_gate_allowed(gate, &user_context))
|
||||
}
|
||||
|
||||
pub async fn list_admin_work_visibility(
|
||||
&self,
|
||||
admin_user_id: String,
|
||||
@@ -669,11 +789,12 @@ impl AppState {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn is_creation_entry_route_enabled(
|
||||
pub async fn is_creation_entry_route_enabled_for_user(
|
||||
&self,
|
||||
creation_type_id: &str,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<bool, SpacetimeClientError> {
|
||||
let config = self.get_creation_entry_config().await?;
|
||||
let config = self.get_creation_entry_config_for_user(user_id).await?;
|
||||
Ok(config
|
||||
.creation_types
|
||||
.iter()
|
||||
@@ -682,6 +803,36 @@ impl AppState {
|
||||
.unwrap_or(true))
|
||||
}
|
||||
|
||||
async fn feature_gate_user_context(
|
||||
&self,
|
||||
user_id: Option<&str>,
|
||||
include_user_tags: bool,
|
||||
) -> module_runtime::FeatureGateUserContext {
|
||||
let user_id = user_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let user_tags = if include_user_tags {
|
||||
match user_id.as_ref() {
|
||||
Some(user_id) => match self.spacetime_client.get_user_tags(user_id.clone()).await {
|
||||
Ok(tags) => tags,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
user_id = %user_id,
|
||||
error = %error,
|
||||
"读取灰度用户标签失败,按无标签继续判定"
|
||||
);
|
||||
vec![]
|
||||
}
|
||||
},
|
||||
None => vec![],
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
module_runtime::FeatureGateUserContext { user_id, user_tags }
|
||||
}
|
||||
|
||||
pub async fn is_public_work_interaction_enabled(
|
||||
&self,
|
||||
source_type: &str,
|
||||
@@ -769,6 +920,14 @@ impl AppState {
|
||||
self.cache_test_creation_entry_config(config);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_feature_gate_config(
|
||||
&self,
|
||||
config: Vec<module_runtime::FeatureGateConfigSnapshot>,
|
||||
) {
|
||||
self.cache_test_feature_gate_config(config);
|
||||
}
|
||||
|
||||
pub fn oss_client(&self) -> Option<&OssClient> {
|
||||
self.oss_client.as_ref()
|
||||
}
|
||||
@@ -1054,6 +1213,25 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
fn feature_gate_requires_user_tags(gate: &module_runtime::FeatureGateConfigSnapshot) -> bool {
|
||||
gate.enabled && !gate.allow_user_tags.is_empty()
|
||||
}
|
||||
|
||||
fn creation_entry_feature_gates_require_user_tags(
|
||||
config: &CreationEntryConfigResponse,
|
||||
gates: &[module_runtime::FeatureGateConfigSnapshot],
|
||||
) -> bool {
|
||||
let gate_keys = config
|
||||
.creation_types
|
||||
.iter()
|
||||
.map(|entry| module_runtime::creation_entry_feature_gate_key(&entry.id))
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
gates
|
||||
.iter()
|
||||
.any(|gate| gate_keys.contains(&gate.gate_key) && feature_gate_requires_user_tags(gate))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl AppState {
|
||||
pub(crate) fn seed_test_refresh_session_for_user(
|
||||
@@ -1119,6 +1297,26 @@ impl AppState {
|
||||
.unwrap_or_else(crate::creation_entry_config::test_creation_entry_config_response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn cache_test_feature_gate_config(
|
||||
&self,
|
||||
config: Vec<module_runtime::FeatureGateConfigSnapshot>,
|
||||
) {
|
||||
*self
|
||||
.test_feature_gate_config
|
||||
.lock()
|
||||
.expect("test feature gate config should lock") = Some(config);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn read_test_feature_gate_config(&self) -> Vec<module_runtime::FeatureGateConfigSnapshot> {
|
||||
self.test_feature_gate_config
|
||||
.lock()
|
||||
.expect("test feature gate config should lock")
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) async fn seed_test_phone_user_with_password(
|
||||
&self,
|
||||
phone_number: &str,
|
||||
@@ -1557,6 +1755,14 @@ fn is_missing_creation_entry_config_procedure(error: &SpacetimeClientError) -> b
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn is_missing_feature_gate_config_procedure(error: &SpacetimeClientError) -> bool {
|
||||
match error {
|
||||
SpacetimeClientError::Procedure(message) => message.contains("No such procedure"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use module_ai::{AiTaskKind, generate_ai_task_id};
|
||||
@@ -1579,6 +1785,58 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_missing_feature_gate_config_procedure_for_debug_fallback() {
|
||||
assert!(is_missing_feature_gate_config_procedure(
|
||||
&SpacetimeClientError::Procedure(
|
||||
"No such procedure: get_feature_gate_config".to_string(),
|
||||
),
|
||||
));
|
||||
assert!(!is_missing_feature_gate_config_procedure(
|
||||
&SpacetimeClientError::Timeout(SpacetimeClientStage::ProcedureResult),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_gate_user_tags_are_only_required_for_enabled_tag_allowlist() {
|
||||
let mut gate = test_feature_gate("image-editor:agent-sidebar");
|
||||
|
||||
assert!(!feature_gate_requires_user_tags(&gate));
|
||||
|
||||
gate.allow_user_tags = vec!["beta".to_string()];
|
||||
assert!(feature_gate_requires_user_tags(&gate));
|
||||
|
||||
gate.enabled = false;
|
||||
assert!(!feature_gate_requires_user_tags(&gate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_tag_lookup_only_needs_matching_enabled_tag_gate() {
|
||||
let config = crate::creation_entry_config::test_creation_entry_config_response();
|
||||
|
||||
let mut unrelated_gate = test_feature_gate("image-editor:agent-sidebar");
|
||||
unrelated_gate.allow_user_tags = vec!["beta".to_string()];
|
||||
assert!(!creation_entry_feature_gates_require_user_tags(
|
||||
&config,
|
||||
&[unrelated_gate],
|
||||
));
|
||||
|
||||
let mut disabled_gate = test_feature_gate("creation-entry:puzzle");
|
||||
disabled_gate.enabled = false;
|
||||
disabled_gate.allow_user_tags = vec!["beta".to_string()];
|
||||
assert!(!creation_entry_feature_gates_require_user_tags(
|
||||
&config,
|
||||
&[disabled_gate],
|
||||
));
|
||||
|
||||
let mut matching_gate = test_feature_gate("creation-entry:puzzle");
|
||||
matching_gate.allow_user_tags = vec!["beta".to_string()];
|
||||
assert!(creation_entry_feature_gates_require_user_tags(
|
||||
&config,
|
||||
&[matching_gate],
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_state_exposes_usable_ai_task_service() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
@@ -1646,6 +1904,19 @@ mod tests {
|
||||
assert!(!client.config().official_fallback());
|
||||
}
|
||||
|
||||
fn test_feature_gate(gate_key: &str) -> module_runtime::FeatureGateConfigSnapshot {
|
||||
module_runtime::FeatureGateConfigSnapshot {
|
||||
gate_key: gate_key.to_string(),
|
||||
enabled: true,
|
||||
rollout_percent: 0,
|
||||
allow_user_ids: vec![],
|
||||
allow_user_tags: vec![],
|
||||
deny_user_ids: vec![],
|
||||
description: String::new(),
|
||||
updated_at_micros: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_api_state_exposes_puzzle_dependency_snapshot() {
|
||||
let mut config = AppConfig::default();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use serde_json::Value;
|
||||
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::domain::*;
|
||||
use crate::errors::RuntimeProfileFieldError;
|
||||
@@ -73,6 +73,162 @@ pub fn build_creation_entry_config_response(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn creation_entry_feature_gate_key(creation_type_id: &str) -> String {
|
||||
format!("creation-entry:{}", creation_type_id.trim())
|
||||
}
|
||||
|
||||
pub const IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY: &str = "image-editor:agent-sidebar";
|
||||
|
||||
pub fn apply_feature_gates_to_creation_entry_config(
|
||||
mut config: CreationEntryConfigResponse,
|
||||
gates: &[FeatureGateConfigSnapshot],
|
||||
user: &FeatureGateUserContext,
|
||||
) -> CreationEntryConfigResponse {
|
||||
let gates_by_key = gates
|
||||
.iter()
|
||||
.map(|gate| (gate.gate_key.as_str(), gate))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
for entry in &mut config.creation_types {
|
||||
let gate_key = creation_entry_feature_gate_key(&entry.id);
|
||||
if !is_feature_gate_allowed(gates_by_key.get(gate_key.as_str()).copied(), user) {
|
||||
entry.visible = false;
|
||||
entry.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
pub fn is_feature_gate_allowed(
|
||||
gate: Option<&FeatureGateConfigSnapshot>,
|
||||
user: &FeatureGateUserContext,
|
||||
) -> bool {
|
||||
let Some(gate) = gate else {
|
||||
return true;
|
||||
};
|
||||
if !gate.enabled {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(user_id) = user
|
||||
.user_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if gate.deny_user_ids.iter().any(|id| id == user_id) {
|
||||
return false;
|
||||
}
|
||||
if gate.allow_user_ids.iter().any(|id| id == user_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let user_tags = user
|
||||
.user_tags
|
||||
.iter()
|
||||
.map(|tag| tag.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if gate
|
||||
.allow_user_tags
|
||||
.iter()
|
||||
.any(|tag| user_tags.contains(tag.as_str()))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if gate.rollout_percent == 0 {
|
||||
return false;
|
||||
}
|
||||
if gate.rollout_percent >= 100 {
|
||||
return true;
|
||||
}
|
||||
|
||||
stable_feature_gate_bucket(user_id, &gate.gate_key) < gate.rollout_percent
|
||||
}
|
||||
|
||||
pub fn stable_feature_gate_bucket(user_id: &str, gate_key: &str) -> u32 {
|
||||
let mut hash = 0xcbf29ce484222325_u64;
|
||||
for byte in user_id
|
||||
.trim()
|
||||
.bytes()
|
||||
.chain([0xff])
|
||||
.chain(gate_key.trim().bytes())
|
||||
{
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
(hash % 100) as u32
|
||||
}
|
||||
|
||||
pub fn normalize_feature_gate_admin_upsert_input(
|
||||
input: FeatureGateConfigAdminUpsertInput,
|
||||
) -> Result<FeatureGateConfigAdminUpsertInput, String> {
|
||||
let gate_key = normalize_feature_gate_key(input.gate_key)?;
|
||||
let description = normalize_feature_gate_description(input.description)?;
|
||||
Ok(FeatureGateConfigAdminUpsertInput {
|
||||
gate_key,
|
||||
enabled: input.enabled,
|
||||
rollout_percent: input.rollout_percent.min(100),
|
||||
allow_user_ids: normalize_feature_gate_list(input.allow_user_ids, "用户 ID 白名单")?,
|
||||
allow_user_tags: normalize_feature_gate_list(input.allow_user_tags, "用户标签白名单")?,
|
||||
deny_user_ids: normalize_feature_gate_list(input.deny_user_ids, "用户 ID 黑名单")?,
|
||||
description,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_feature_gate_key(value: String) -> Result<String, String> {
|
||||
let gate_key = value.trim().to_string();
|
||||
if gate_key.is_empty() {
|
||||
return Err("灰度 key 不能为空".to_string());
|
||||
}
|
||||
if gate_key.len() > FEATURE_GATE_KEY_MAX_CHARS {
|
||||
return Err(format!(
|
||||
"灰度 key 最多允许 {} 个字符",
|
||||
FEATURE_GATE_KEY_MAX_CHARS
|
||||
));
|
||||
}
|
||||
Ok(gate_key)
|
||||
}
|
||||
|
||||
fn normalize_feature_gate_description(value: String) -> Result<String, String> {
|
||||
let description = value.trim().to_string();
|
||||
if description.len() > FEATURE_GATE_DESCRIPTION_MAX_CHARS {
|
||||
return Err(format!(
|
||||
"灰度说明最多允许 {} 个字符",
|
||||
FEATURE_GATE_DESCRIPTION_MAX_CHARS
|
||||
));
|
||||
}
|
||||
Ok(description)
|
||||
}
|
||||
|
||||
fn normalize_feature_gate_list(values: Vec<String>, label: &str) -> Result<Vec<String>, String> {
|
||||
let mut seen = BTreeSet::<String>::new();
|
||||
for value in values {
|
||||
let item = value.trim().to_string();
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if item.len() > FEATURE_GATE_LIST_ITEM_MAX_CHARS {
|
||||
return Err(format!(
|
||||
"{label}单项最多允许 {} 个字符",
|
||||
FEATURE_GATE_LIST_ITEM_MAX_CHARS
|
||||
));
|
||||
}
|
||||
seen.insert(item);
|
||||
}
|
||||
if seen.len() > FEATURE_GATE_LIST_MAX_COUNT {
|
||||
return Err(format!(
|
||||
"{label}最多允许 {} 项",
|
||||
FEATURE_GATE_LIST_MAX_COUNT
|
||||
));
|
||||
}
|
||||
Ok(seen.into_iter().collect())
|
||||
}
|
||||
|
||||
/// 返回公开作品点赞 / 改造默认矩阵,保持历史前端硬编码能力不变。
|
||||
pub fn default_public_work_interaction_config_snapshots() -> Vec<PublicWorkInteractionConfigSnapshot>
|
||||
{
|
||||
@@ -2172,3 +2328,117 @@ fn parse_optional_json_value(
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn feature_gate_denies_anonymous_when_enabled() {
|
||||
let gate = test_gate("creation-entry:puzzle");
|
||||
|
||||
assert!(!is_feature_gate_allowed(
|
||||
Some(&gate),
|
||||
&FeatureGateUserContext::default(),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_gate_deny_user_ids_override_allow_rules() {
|
||||
let mut gate = test_gate("creation-entry:puzzle");
|
||||
gate.allow_user_ids = vec!["user-1".to_string()];
|
||||
gate.allow_user_tags = vec!["vip".to_string()];
|
||||
gate.deny_user_ids = vec!["user-1".to_string()];
|
||||
gate.rollout_percent = 100;
|
||||
|
||||
assert!(!is_feature_gate_allowed(
|
||||
Some(&gate),
|
||||
&FeatureGateUserContext {
|
||||
user_id: Some("user-1".to_string()),
|
||||
user_tags: vec!["vip".to_string()],
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_gate_allows_explicit_user_tag() {
|
||||
let mut gate = test_gate("creation-entry:puzzle");
|
||||
gate.allow_user_tags = vec!["vip".to_string()];
|
||||
|
||||
assert!(is_feature_gate_allowed(
|
||||
Some(&gate),
|
||||
&FeatureGateUserContext {
|
||||
user_id: Some("user-2".to_string()),
|
||||
user_tags: vec!["vip".to_string()],
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_entry_gate_sets_denied_entry_hidden_and_closed() {
|
||||
let config = CreationEntryConfigResponse {
|
||||
start_card: CreationEntryStartCardResponse {
|
||||
title: String::new(),
|
||||
description: String::new(),
|
||||
idle_badge: String::new(),
|
||||
busy_badge: String::new(),
|
||||
},
|
||||
type_modal: CreationEntryTypeModalResponse {
|
||||
title: String::new(),
|
||||
description: String::new(),
|
||||
},
|
||||
event_banner: CreationEntryEventBannerResponse {
|
||||
title: String::new(),
|
||||
description: String::new(),
|
||||
cover_image_src: String::new(),
|
||||
prize_pool_mud_points: 0,
|
||||
starts_at_text: String::new(),
|
||||
ends_at_text: String::new(),
|
||||
render_mode: "structured".to_string(),
|
||||
html_code: None,
|
||||
},
|
||||
event_banners: vec![],
|
||||
public_work_interactions: vec![],
|
||||
creation_types: vec![CreationEntryTypeResponse {
|
||||
id: "puzzle".to_string(),
|
||||
title: "拼图".to_string(),
|
||||
subtitle: String::new(),
|
||||
badge: String::new(),
|
||||
image_src: String::new(),
|
||||
visible: true,
|
||||
open: true,
|
||||
sort_order: 1,
|
||||
category_id: DEFAULT_CREATION_ENTRY_CATEGORY_ID.to_string(),
|
||||
category_label: DEFAULT_CREATION_ENTRY_CATEGORY_LABEL.to_string(),
|
||||
category_sort_order: 0,
|
||||
updated_at_micros: 1,
|
||||
unified_creation_spec: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let filtered = apply_feature_gates_to_creation_entry_config(
|
||||
config,
|
||||
&[test_gate("creation-entry:puzzle")],
|
||||
&FeatureGateUserContext {
|
||||
user_id: Some("user-3".to_string()),
|
||||
user_tags: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
assert!(!filtered.creation_types[0].visible);
|
||||
assert!(!filtered.creation_types[0].open);
|
||||
}
|
||||
|
||||
fn test_gate(gate_key: &str) -> FeatureGateConfigSnapshot {
|
||||
FeatureGateConfigSnapshot {
|
||||
gate_key: gate_key.to_string(),
|
||||
enabled: true,
|
||||
rollout_percent: 0,
|
||||
allow_user_ids: vec![],
|
||||
allow_user_tags: vec![],
|
||||
deny_user_ids: vec![],
|
||||
description: String::new(),
|
||||
updated_at_micros: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,10 @@ pub const CREATION_ENTRY_EVENT_BANNERS_MAX_COUNT: usize = 8;
|
||||
pub const CREATION_ENTRY_EVENT_BANNER_HTML_CODE_MAX_BYTES: usize = 12_000;
|
||||
/// 公开作品互动配置最多允许覆盖的 sourceType 数量。
|
||||
pub const PUBLIC_WORK_INTERACTION_CONFIG_MAX_COUNT: usize = 32;
|
||||
pub const FEATURE_GATE_KEY_MAX_CHARS: usize = 128;
|
||||
pub const FEATURE_GATE_DESCRIPTION_MAX_CHARS: usize = 240;
|
||||
pub const FEATURE_GATE_LIST_MAX_COUNT: usize = 500;
|
||||
pub const FEATURE_GATE_LIST_ITEM_MAX_CHARS: usize = 128;
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -185,6 +189,46 @@ pub struct CreationEntryConfigProcedureResult {
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
/// 通用功能灰度配置,当前首个业务接入点是创作入口 `creation-entry:<id>`。
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FeatureGateConfigSnapshot {
|
||||
pub gate_key: String,
|
||||
pub enabled: bool,
|
||||
pub rollout_percent: u32,
|
||||
pub allow_user_ids: Vec<String>,
|
||||
pub allow_user_tags: Vec<String>,
|
||||
pub deny_user_ids: Vec<String>,
|
||||
pub description: String,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FeatureGateConfigAdminUpsertInput {
|
||||
pub gate_key: String,
|
||||
pub enabled: bool,
|
||||
pub rollout_percent: u32,
|
||||
pub allow_user_ids: Vec<String>,
|
||||
pub allow_user_tags: Vec<String>,
|
||||
pub deny_user_ids: Vec<String>,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FeatureGateConfigProcedureResult {
|
||||
pub ok: bool,
|
||||
pub records: Vec<FeatureGateConfigSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FeatureGateUserContext {
|
||||
pub user_id: Option<String>,
|
||||
pub user_tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// 后台作品可见性列表项。
|
||||
///
|
||||
/// source_type/profile_id 是后台统一操作键;少数玩法的 profile_id 会映射到底层
|
||||
|
||||
@@ -83,6 +83,40 @@ pub struct AdminUpsertPublicWorkInteractionConfigRequest {
|
||||
pub public_work_interactions: Vec<PublicWorkInteractionConfigResponse>,
|
||||
}
|
||||
|
||||
/// 后台灰度配置列表响应。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminFeatureGateConfigResponse {
|
||||
pub gates: Vec<AdminFeatureGateConfigPayload>,
|
||||
}
|
||||
|
||||
/// 后台单个灰度 gate 配置。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminFeatureGateConfigPayload {
|
||||
pub gate_key: String,
|
||||
pub enabled: bool,
|
||||
pub rollout_percent: u32,
|
||||
pub allow_user_ids: Vec<String>,
|
||||
pub allow_user_tags: Vec<String>,
|
||||
pub deny_user_ids: Vec<String>,
|
||||
pub description: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 后台保存灰度 gate 配置请求。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUpsertFeatureGateConfigRequest {
|
||||
pub gate_key: String,
|
||||
pub enabled: bool,
|
||||
pub rollout_percent: u32,
|
||||
pub allow_user_ids: Vec<String>,
|
||||
pub allow_user_tags: Vec<String>,
|
||||
pub deny_user_ids: Vec<String>,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// 后台作品可见性列表项。
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -56,8 +56,8 @@ pub use mapper::{
|
||||
ExternalGenerationJobGetRecordInput, ExternalGenerationJobListRecord,
|
||||
ExternalGenerationJobListRecordInput, ExternalGenerationJobRecord,
|
||||
ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueStatsRecord,
|
||||
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,
|
||||
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
|
||||
FeatureGateConfigRecord, JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType,
|
||||
JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse,
|
||||
JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus,
|
||||
JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath,
|
||||
JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus,
|
||||
@@ -357,6 +357,7 @@ pub struct SpacetimeClient {
|
||||
pool: Arc<SpacetimeConnectionPool>,
|
||||
health_state: Arc<RwLock<SpacetimeClientHealthState>>,
|
||||
creation_entry_config_cache: Arc<RwLock<Option<CreationEntryConfigRecord>>>,
|
||||
feature_gate_config_cache: Arc<RwLock<Option<Vec<FeatureGateConfigRecord>>>>,
|
||||
custom_world_gallery_legacy_sync_attempted: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
@@ -466,6 +467,7 @@ impl SpacetimeClient {
|
||||
pool,
|
||||
health_state: Arc::new(RwLock::new(SpacetimeClientHealthState::default())),
|
||||
creation_entry_config_cache: Arc::new(RwLock::new(None)),
|
||||
feature_gate_config_cache: Arc::new(RwLock::new(None)),
|
||||
custom_world_gallery_legacy_sync_attempted: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
@@ -657,6 +659,14 @@ impl SpacetimeClient {
|
||||
self.creation_entry_config_cache.read().await.clone()
|
||||
}
|
||||
|
||||
async fn cache_feature_gate_config(&self, config: Vec<FeatureGateConfigRecord>) {
|
||||
*self.feature_gate_config_cache.write().await = Some(config);
|
||||
}
|
||||
|
||||
async fn read_cached_feature_gate_config(&self) -> Option<Vec<FeatureGateConfigRecord>> {
|
||||
self.feature_gate_config_cache.read().await.clone()
|
||||
}
|
||||
|
||||
pub async fn health_check(&self, probe_timeout: Duration) -> SpacetimeClientHealthSnapshot {
|
||||
let timeout = if probe_timeout.is_zero() {
|
||||
DEFAULT_PROCEDURE_TIMEOUT
|
||||
@@ -898,6 +908,7 @@ impl SpacetimeClient {
|
||||
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'",
|
||||
"SELECT * FROM creation_entry_config",
|
||||
"SELECT * FROM creation_entry_type_config",
|
||||
"SELECT * FROM user_account",
|
||||
"SELECT * FROM asset_object",
|
||||
] {
|
||||
if let Ok(subscription) = self
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user