Merge remote-tracking branch 'origin/codex/gray-release'

This commit is contained in:
2026-07-08 20:44:29 +08:00
43 changed files with 2675 additions and 54 deletions
+19
View File
@@ -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',
+20
View File
@@ -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[];
+7
View File
@@ -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}
+2
View File
@@ -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',
+2
View File
@@ -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
+10
View File
@@ -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
+82 -3
View File
@@ -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> {
+162
View File
@@ -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");
+9
View File
@@ -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)
+274 -3
View File
@@ -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")]
+13 -2
View File
@@ -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