From b72e2163d70bee5b3683654e2f5e87af07aab954 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 23 Jul 2026 14:23:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=90=8E=E5=8F=B0=E7=81=B0?= =?UTF-8?q?=E5=BA=A6=E5=8F=91=E5=B8=83=E6=8E=A7=E5=88=B6=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复灰度发布导航、权限、前端契约与页面编译链 解除灰度页面对旧创作入口配置的依赖 补充路由、API、页面回归测试及当前架构文档 --- .eslintrc.cjs | 1 - apps/admin-web/src/api/adminApiClient.test.ts | 48 ++++++++++++ apps/admin-web/src/api/adminApiClient.ts | 19 +++++ apps/admin-web/src/api/adminApiTypes.ts | 20 +++++ apps/admin-web/src/app/AdminApp.tsx | 7 ++ apps/admin-web/src/app/AdminShell.tsx | 2 + apps/admin-web/src/app/adminRoutes.test.ts | 23 +++++- apps/admin-web/src/app/adminRoutes.ts | 4 +- .../pages/AdminGrayReleaseConfigPage.test.tsx | 76 +------------------ .../src/pages/AdminGrayReleaseConfigPage.tsx | 38 +--------- apps/admin-web/tsconfig.json | 2 - .../shared-memory/decision-log.md | 8 ++ docs/project-memory/shared-memory/pitfalls.md | 8 ++ ...管理】多账号与Tab访问权限方案-2026-07-14.md | 42 +++++----- ...构下线】旧创作模板业务退役方案-2026-07-17.md | 1 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + vitest.config.ts | 1 - 17 files changed, 163 insertions(+), 138 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 1536699b8..3b4cf7c4d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -386,7 +386,6 @@ module.exports = { 'src/data/**', 'src/prompts/**', 'apps/admin-web/src/pages/AdminCreationEntrySwitchPage*', - 'apps/admin-web/src/pages/AdminGrayReleaseConfigPage*', 'apps/admin-web/src/pages/AdminWorkVisibilityPage*', 'src/services/recommendedRuntimeGuestLaunch.test.ts', 'src/data/sceneEncounterPreviews.ts', diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 68fbc4a54..97ec8bbb6 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -3,11 +3,13 @@ import { afterEach, expect, test, vi } from 'vitest'; import { createAdminAccount, executeAdminRechargeRefund, + getAdminFeatureGateConfig, getAdminUserDetail, listAdminRechargeOrders, resolveAdminRechargeRefundManualReview, updateAdminAccount, uploadAdminEditorShowcaseCampaignImage, + upsertAdminFeatureGateConfig, } from './adminApiClient'; afterEach(() => { @@ -57,6 +59,52 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => ); }); +test('灰度配置读写只使用通用 feature-gates 管理接口', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ gates: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await getAdminFeatureGateConfig('gray-token'); + await upsertAdminFeatureGateConfig('gray-token', { + gateKey: 'image-editor:agent-sidebar', + enabled: true, + rolloutPercent: 25, + allowUserIds: ['user-1'], + allowUserTags: ['beta'], + denyUserIds: ['blocked-1'], + description: '画布 Agent 入口灰度', + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('/admin/api/feature-gates'); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer gray-token' }), + }), + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/feature-gates'); + expect(fetchMock.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ Authorization: 'Bearer gray-token' }), + body: JSON.stringify({ + gateKey: 'image-editor:agent-sidebar', + enabled: true, + rolloutPercent: 25, + allowUserIds: ['user-1'], + allowUserTags: ['beta'], + denyUserIds: ['blocked-1'], + description: '画布 Agent 入口灰度', + }), + }), + ); +}); + test('活动卡图片上传成功后先确认正式私有对象再返回图片引用', async () => { const closeBitmap = vi.fn(); vi.stubGlobal( diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index c5f17d5b5..e3697bb90 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -23,6 +23,7 @@ import type { AdminEditorShowcaseListQuery, AdminEditorShowcaseListResponse, AdminEditorShowcaseReviewRequest, + AdminFeatureGateConfigResponse, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, @@ -41,6 +42,7 @@ import type { AdminUpdateAccountResponse, AdminUploadedEditorShowcaseCampaignImage, AdminUpsertEditorShowcaseCampaignRequest, + AdminUpsertFeatureGateConfigRequest, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, @@ -267,6 +269,23 @@ export function listAdminTrackingEventKeys(token: string) { ); } +export function getAdminFeatureGateConfig(token: string) { + return request('/admin/api/feature-gates', { + token, + }); +} + +export function upsertAdminFeatureGateConfig( + token: string, + payload: AdminUpsertFeatureGateConfigRequest, +) { + return request('/admin/api/feature-gates', { + method: 'PUT', + token, + body: payload, + }); +} + export function getAdminEditorGenerationPricing(token: string) { return request( '/admin/api/editor-generation-pricing', diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 879af5c51..3900ab01d 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -286,6 +286,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 type EditorGenerationPricingUnitPayload = 'perGeneration' | 'perSecond'; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 1ee731fc7..de33c8761 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -24,6 +24,7 @@ import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage'; import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage'; import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage'; import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage'; +import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; import { AdminOverviewPage } from '../pages/AdminOverviewPage'; @@ -227,6 +228,12 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {activeRouteId === 'gray-release' ? ( + + ) : null} {activeRouteId === 'redeem' ? ( { ); }); +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('后台不再暴露旧创作模板管理路由', () => { expect(resolveAdminRoute('#creation-entry')).toBe('dashboard'); expect(resolveAdminRoute('#creation-announcement')).toBe('dashboard'); - expect(resolveAdminRoute('#gray-release')).toBe('dashboard'); expect(resolveAdminRoute('#work-visibility')).toBe('dashboard'); }); + test('后台素材查询路由可通过导航和 hash 访问', () => { expect(adminRoutes).toContainEqual({ id: 'editor-assets', @@ -93,6 +103,17 @@ test('member 只访问已分配 Tab 且无权 hash 回落到第一项', () => { ); }); +test('member 可单独获得灰度发布 Tab 权限', () => { + const routes = getAccessibleAdminRoutes({ + accountRole: 'member', + tabPermissions: ['gray-release'], + }); + expect(routes.map((route) => route.id)).toEqual(['gray-release']); + expect(resolveAccessibleAdminRoute('#gray-release', routes)).toBe( + 'gray-release', + ); +}); + test('零权限 member 不回落到 Dashboard', () => { const routes = getAccessibleAdminRoutes({ accountRole: 'member', diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index faf97b083..b3bad3b50 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -1,10 +1,11 @@ -/** 后台单页应用可导航的路由标识,入口公告独立于入口开关维护。 */ +/** 后台单页应用可导航的路由标识。 */ export type AdminRouteId = | 'dashboard' | 'overview' | 'tables' | 'debug' | 'tracking' + | 'gray-release' | 'redeem' | 'invite' | 'profile-wallet' @@ -32,6 +33,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' }, diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx index e7f82b84e..a48273fae 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx @@ -5,21 +5,16 @@ 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 type { 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(), @@ -50,48 +45,8 @@ const configResponse: AdminFeatureGateConfigResponse = { ], }; -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); }); @@ -109,7 +64,6 @@ test('灰度发布页加载并展示 gate 列表', async () => { ).toBeTruthy(); expect(screen.getByText('25%')).toBeTruthy(); expect(getAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token'); - expect(getAdminCreationEntryConfig).toHaveBeenCalledWith('admin-token'); }); test('灰度发布页可选择已有 gate 编辑', async () => { @@ -152,12 +106,11 @@ test('灰度发布页选择新 target 时重置旧 gate 规则', async () => { await screen.findByRole('button', { name: 'editor.new-toolbar' }), ); await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [ - 'creation-entry', + 'image-editor', ]); - await user.selectOptions(screen.getByLabelText('Gate Key 目标'), ['match3d']); expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe( - 'creation-entry:match3d', + 'image-editor:agent-sidebar', ); expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe( false, @@ -175,27 +128,7 @@ test('灰度发布页选择新 target 时重置旧 gate 规则', async () => { (screen.getByLabelText('拒绝用户 ID') as HTMLTextAreaElement).value, ).toBe(''); expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe( - '3D 消除创作入口灰度', - ); -}); - -test('灰度发布页可通过创作入口生成 Gate Key', async () => { - const user = userEvent.setup(); - render( - , - ); - - 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( - '拼图创作入口灰度', + '画布 Agent 入口灰度', ); }); @@ -281,7 +214,6 @@ test('灰度发布页保存时转换数组和百分比', async () => { test('灰度发布页无 token 时不请求配置', () => { render(); - expect(getAdminCreationEntryConfig).not.toHaveBeenCalled(); expect(getAdminFeatureGateConfig).not.toHaveBeenCalled(); expect(upsertAdminFeatureGateConfig).not.toHaveBeenCalled(); }); diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx index 38d0e1de6..00d6d742f 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx @@ -1,13 +1,11 @@ import { Plus, RefreshCcw, Save } from 'lucide-react'; -import { FormEvent, useEffect, useState } from 'react'; +import { type FormEvent, useEffect, useState } from 'react'; import { - getAdminCreationEntryConfig, getAdminFeatureGateConfig, upsertAdminFeatureGateConfig, } from '../api/adminApiClient'; import type { - AdminCreationEntryTypeConfigPayload, AdminFeatureGateConfigPayload, AdminUpsertFeatureGateConfigRequest, } from '../api/adminApiTypes'; @@ -28,7 +26,6 @@ interface GateTargetOption { } const GATE_PREFIX_LABELS: Record = { - 'creation-entry': '创作入口', 'image-editor': '画布', }; @@ -47,9 +44,6 @@ export function AdminGrayReleaseConfigPage({ onUnauthorized, }: AdminGrayReleaseConfigPageProps) { const [gates, setGates] = useState([]); - const [creationEntries, setCreationEntries] = useState< - AdminCreationEntryTypeConfigPayload[] - >([]); const [selectedGateKey, setSelectedGateKey] = useState(''); const [gatePrefix, setGatePrefix] = useState(''); const [gateKey, setGateKey] = useState(''); @@ -74,7 +68,6 @@ export function AdminGrayReleaseConfigPage({ const requestToken = token.trim(); if (!requestToken) { setGates([]); - setCreationEntries([]); setListErrorMessage(''); setIsLoading(false); return; @@ -83,12 +76,8 @@ export function AdminGrayReleaseConfigPage({ setIsLoading(true); setListErrorMessage(''); try { - const [featureGateResponse, creationEntryResponse] = await Promise.all([ - getAdminFeatureGateConfig(requestToken), - getAdminCreationEntryConfig(requestToken), - ]); + const featureGateResponse = await getAdminFeatureGateConfig(requestToken); setGates(featureGateResponse.gates); - setCreationEntries(creationEntryResponse.entries); const selectedGate = featureGateResponse.gates.find( (gate) => gate.gateKey === selectedGateKey, ); @@ -240,7 +229,7 @@ export function AdminGrayReleaseConfigPage({ const canSave = gateKey.trim().length > 0 && isRolloutPercentInputValid(rolloutPercent); - const gateTargetOptions = buildGateTargetOptions(creationEntries); + const gateTargetOptions = FIXED_GATE_TARGETS; const gatePrefixOptions = buildGatePrefixOptions(gateTargetOptions); const gateTargetsForPrefix = gateTargetOptions.filter( (option) => option.prefix === gatePrefix, @@ -466,27 +455,6 @@ function isRolloutPercentInputValid(value: string) { return Number.isFinite(parsed) && parsed >= 0 && parsed <= 100; } -function creationEntryGateKey(entryId: string) { - return `creation-entry:${entryId.trim()}`; -} - -function buildGateTargetOptions( - creationEntries: AdminCreationEntryTypeConfigPayload[], -): GateTargetOption[] { - return [ - ...creationEntries.map((entry) => ({ - prefix: 'creation-entry', - suffix: entry.id, - key: creationEntryGateKey(entry.id), - label: entry.title.trim() || entry.id, - description: entry.title.trim() - ? `${entry.title.trim()}创作入口灰度` - : '创作入口灰度', - })), - ...FIXED_GATE_TARGETS, - ]; -} - function buildGatePrefixOptions(gateTargetOptions: GateTargetOption[]) { const seen = new Set(); return gateTargetOptions.flatMap((option) => { diff --git a/apps/admin-web/tsconfig.json b/apps/admin-web/tsconfig.json index 54fad2320..ad730bdf1 100644 --- a/apps/admin-web/tsconfig.json +++ b/apps/admin-web/tsconfig.json @@ -19,8 +19,6 @@ "exclude": [ "src/pages/AdminCreationEntrySwitchPage.tsx", "src/pages/AdminCreationEntrySwitchPage.test.tsx", - "src/pages/AdminGrayReleaseConfigPage.tsx", - "src/pages/AdminGrayReleaseConfigPage.test.tsx", "src/pages/AdminWorkVisibilityPage.tsx" ] } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index af571d627..6d94b0d8e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4406,3 +4406,11 @@ - 动态与可用性边界:`useLayoutEffect` 首次同步测量,`ResizeObserver + requestAnimationFrame` 在容器变宽、卡高变化、筛选重排和 cursor 追加后全量重排。只有当宽度、卡数和所有高度完整时才进入 absolute ready;否则保留 Grid fallback,防止卡片重叠和分页 sentinel 提前触发。DOM/Tab/读屏顺序始终不变,容器与卡片显式为 list/listitem。 - 兼容边界:保留现有 `.creation-landing__asset-waterfall` 类名、筛选、排序、cursor 分页、预览与点赞链路;只替换布局算法。该决策覆盖 2026-07-07 multi-column 及本日早先 row-major Grid 的布局部分,不改变精选仍是动态素材流的产品定位。 - 验证方式:纯函数测试锁定容器临界宽度、循环列序、列内 top 和容器高度;`src/index.test.ts` 锁定 Grid fallback 与 Masonry ready。Playwright 在同一 viewport 中变更容器宽度,核对 3/2/1 列、每列 gap、容器高度、DOM 顺序、无重叠/横溢出和 console/page error。 + +## 2026-07-23 恢复通用灰度发布后台控制面 + +- 背景:旧创作模板退役时,后台灰度页因同时加载 `creation-entry:*` 动态目标与现役 `image-editor:agent-sidebar` 固定目标,被整页从路由、TypeScript、ESLint 和 Vitest 编译链摘除;通用 feature gate 后端、权限和现役画布 Agent 判定仍在,形成有 API 无正式控制面的不一致。 +- 决策:恢复后台 `#gray-release` 导航、member Tab 权限展示、前端 DTO/client、页面渲染和页面测试;页面只读取和写入 `GET/PUT /admin/api/feature-gates`,不再请求已退役 `/admin/api/creation-entry/config`。 +- 目标边界:固定目标列表只登记现役 `image-editor:agent-sidebar`;管理员仍可直接输入其他通用 Gate Key。不得恢复 `creation-entry:*` 动态目标、入口公告、入口开关、旧作品可见性页面或任何旧模板接口。 +- 运行语义:环境变量继续是画布 Agent 总开关,feature gate 只在总开关开启后做黑名单、白名单、标签和稳定百分比受众限制;本次不修改 SpacetimeDB schema、灰度优先级或后端契约。 +- 验证方式:后台路由与灰度页面 Vitest、`npm run admin-web:typecheck`、定向 ESLint、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 7bd8c2c13..db9c44746 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3313,3 +3313,11 @@ - 验证与回滚:重启后先跑真实 PR 的四个 job,再清理旧镜像。失败时先把 workflow `runs-on` 改回 `ubuntu-latest`,再恢复 runner config 备份并重启;不在 Git、共享文档或日志中记录 config 备份路径、注册信息或 token。 - 重启边界:`docker restart --timeout 660` 只设置容器停止宽限,不能替代 Runner drain。rootless DinD supervisor 可能与 runner 同时停止内层 dockerd,使仍在收尾的 job 因连接关闭被标记失败;切换前必须同时确认 Gitea 没有 `in_progress` run 且内层 `docker ps` 为空。误触发时只重跑受影响的失败 job,不重跑已成功项。 - 关联:`deploy/container/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/project-memory/shared-memory/development-workflow.md`。 + +## 通用灰度后台页不能依赖已退役业务配置 + +- 现象:通用 `feature_gate_config`、`/admin/api/feature-gates` 和现役功能 gate 仍在,但后台“灰度发布”Tab 随旧创作模板入口一起消失;Rust 权限仍可授予 `gray-release`,前端却没有对应路由。 +- 原因:灰度页同时请求通用 gate 与旧 `/admin/api/creation-entry/config`,并把 `creation-entry:*` 动态目标和现役固定目标混在同一页面;按页面清理旧入口时连带摘除了通用控制面。 +- 处理:灰度页只能以 `/admin/api/feature-gates` 为数据源,固定目标列表只登记现役功能;新增或退役业务 target 只修改固定目标注册,不得让通用页面依赖业务列表接口。旧 `creation-entry:*` 目标、接口和页面保持退役。 +- 验证:`adminRoutes` 必须包含 `gray-release`,admin-web TypeScript/ESLint/Vitest 不得排除灰度页;页面测试必须断言只请求 feature-gates,并继续覆盖现役固定 target、直接 Gate Key 保存与新 target 状态重置。 +- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx`、`apps/admin-web/src/app/adminRoutes.ts`、`server-rs/crates/api-server/src/modules/admin.rs`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。 diff --git a/docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md b/docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md index d6d31d693..e53d43ace 100644 --- a/docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md +++ b/docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md @@ -1,6 +1,6 @@ # 后台管理多账号与 Tab 访问权限方案 -更新时间:`2026-07-14` +更新时间:`2026-07-23` ## 1. 文档定位 @@ -10,12 +10,12 @@ ## 2. 当前基线与目标 -当前后台由 `GENARRATIVE_ADMIN_USERNAME`、`GENARRATIVE_ADMIN_PASSWORD` 提供唯一管理员账号,`apps/admin-web/src/app/adminRoutes.ts` 定义 18 个一级 Tab,`server-rs/crates/api-server/src/modules/admin.rs` 中的后台路由只校验统一的管理员 JWT。 +当前后台由 `GENARRATIVE_ADMIN_USERNAME`、`GENARRATIVE_ADMIN_PASSWORD` 提供唯一管理员账号,`apps/admin-web/src/app/adminRoutes.ts` 定义 15 个可分配业务 Tab,并另有 owner-only 的“账号管理”Tab;`server-rs/crates/api-server/src/modules/admin.rs` 中的后台路由只校验统一的管理员 JWT。 改造后的目标如下: 1. 现有环境变量账号升级为 `owner`,仍由部署环境提供,不迁移、不复制到 SpacetimeDB。 -2. owner 始终拥有全部 18 个业务 Tab 权限,并独占“账号管理”Tab 和账号管理 API。 +2. owner 始终拥有全部 15 个业务 Tab 权限,并独占“账号管理”Tab 和账号管理 API。 3. owner 可以创建、修改、启停 member;member 保存在 SpacetimeDB 私有表 `admin_account`。 4. member 按一级 Tab 分配权限;获得一个 Tab 权限即获得该页面内全部读写能力,页面内部二级 Tab、弹窗和操作区继承一级权限。 5. member JWT 每次请求都重新读取当前账号并校验 `enabled`、`token_version` 和实时权限,权限、密码或启停变更应立即让旧 JWT 失效。 @@ -26,7 +26,7 @@ - owner 用户名和密码继续读取 `GENARRATIVE_ADMIN_USERNAME`、`GENARRATIVE_ADMIN_PASSWORD`。 - owner 是环境变量构造的虚拟账号,不写入 `admin_account`,不允许通过后台改名、改密、禁用或删除。 -- owner 始终拥有本文列出的全部 18 个可分配权限,不能在前端取消,也不从数据库加载权限。 +- owner 始终拥有本文列出的全部 15 个可分配权限,不能在前端取消,也不从数据库加载权限。 - “账号管理”是 owner-only 能力。它可以作为新增一级路由 `accounts` / `#accounts` 展示,但 `accounts` 不进入 `ADMIN_TAB_PERMISSIONS`,不能写入 member 的 `permissions_json`。 - owner 会话返回 `accountRole = "owner"`、`roles = ["admin", "owner"]`;账号管理权限必须根据服务端确认的 `accountRole` 判断,不能只相信前端角色字符串。 - owner 配置缺失时,后台整体保持未启用状态;不能依赖数据库中的 member 绕过 owner 引导配置启动后台。 @@ -41,7 +41,7 @@ ## 4. 权限标识 -`ADMIN_TAB_PERMISSIONS` 必须是 shared-contracts 与 admin-web 共用的闭合集合,值与现有 `AdminRouteId` 一致。18 个可分配权限如下,顺序同时作为前端寻找“第一可访问项”的稳定顺序: +`ADMIN_TAB_PERMISSIONS` 必须是 shared-contracts 与 admin-web 共用的闭合集合,值与现有 `AdminRouteId` 一致。15 个可分配权限如下,顺序同时作为前端寻找“第一可访问项”的稳定顺序: | permission id | 一级 Tab | hash | | --- | --- | --- | @@ -60,9 +60,6 @@ | `editor-generation-pricing` | 模型定价 | `#editor-generation-pricing` | | `editor-showcase` | 精选审核 | `#editor-showcase` | | `editor-assets` | 素材查询 | `#editor-assets` | -| `creation-announcement` | 入口公告 | `#creation-announcement` | -| `creation-entry` | 入口开关 | `#creation-entry` | -| `work-visibility` | 作品可见性 | `#work-visibility` | 权限数组必须去重并按上表顺序规范化后保存。保存时拒绝未知值和 `accounts`;读取旧数据时遇到未知值应忽略并记录告警,绝不能将未知值解释为全权限。空数组合法,表示 member 可以登录但没有业务页面权限。 @@ -83,7 +80,7 @@ | `username` | `String` | `unique`;登录名,创建后不可修改;按 `trim + ASCII lowercase` 规范化 | | `display_name` | `String` | 展示名,去除首尾空白后 1 至 64 字符 | | `password_hash` | `String` | Argon2id PHC 字符串;只在内部登录查询中返回给 api-server,永不进入 HTTP DTO、日志或前端状态 | -| `permissions_json` | `String` | 规范化后的 Tab permission JSON;只允许第 4 节 18 个值,空数组为 `[]` | +| `permissions_json` | `String` | 规范化后的 Tab permission JSON;只允许第 4 节 15 个值,空数组为 `[]` | | `enabled` | `bool` | 是否允许登录和继续使用现有 JWT | | `token_version` | `u64` | 初始为 `1`;权限、密码或启停状态发生有效变化时加 `1` | | `created_by` | `String` | 创建者后台 subject;当前只能是 owner subject | @@ -163,7 +160,7 @@ accountRole: "owner" | "member" tabPermissions: string[] ``` -owner 返回全部 18 个 permission id;member 返回数据库中的实时规范化数组。`GET /admin/api/me` 同样执行逐请求校验并返回实时权限,供刷新页面后恢复导航。 +owner 返回全部 15 个 permission id;member 返回数据库中的实时规范化数组。`GET /admin/api/me` 同样执行逐请求校验并返回实时权限,供刷新页面后恢复导航。 后台所有面向运营展示的管理员身份统一使用 `displayName`。审计表继续保存稳定 subject,例如 owner subject 或 `admin-account-`;api-server 在返回兑换码、邀请码等操作记录时,按 owner 运行态和 `admin_account` 批量解析显示名称,同时兼容历史用户名记录。已无法解析的历史主体统一展示“已停用管理员”,前端不得直接渲染 `operatorUserId`、账号 ID 或登录用户名代替显示名称。对写接口,显示名目录必须在主事务前加载,或在主事务成功后降级为占位文案;不得因二次读取失败把已提交写入伪装成失败。 @@ -196,10 +193,6 @@ owner 返回全部 18 个 permission id;member 返回数据库中的实时规 | `GET` | `/admin/api/tracking/event-keys` | `tracking OR tasks` | | `GET` | `/admin/api/database/tables` | `tables` | | `GET` | `/admin/api/database/tables/{table_name}/rows` | `tables` | -| `GET` | `/admin/api/creation-entry/config` | `gray-release OR creation-announcement OR creation-entry` | -| `POST` | `/admin/api/creation-entry/config` | `creation-entry` | -| `POST` | `/admin/api/creation-entry/config/banners` | `creation-announcement` | -| `POST` | `/admin/api/creation-entry/config/interactions` | `creation-entry` | | `GET` | `/admin/api/feature-gates` | `gray-release` | | `PUT` | `/admin/api/feature-gates` | `gray-release` | | `GET` | `/admin/api/editor-generation-pricing` | `editor-generation-pricing` | @@ -212,8 +205,6 @@ owner 返回全部 18 个 permission id;member 返回数据库中的实时规 | `GET` | `/admin/api/editor-showcase/campaign` | `editor-showcase` | | `POST` | `/admin/api/editor-showcase/campaign` | `editor-showcase` | | `POST` | `/admin/api/editor-showcase/campaign/image-upload-ticket` | `editor-showcase` | -| `GET` | `/admin/api/works/visibility` | `work-visibility` | -| `POST` | `/admin/api/works/visibility` | `work-visibility` | | `GET` | `/admin/api/profile/redeem-codes` | `redeem` | | `POST` | `/admin/api/profile/redeem-codes` | `redeem` | | `POST` | `/admin/api/profile/redeem-codes/disable` | `redeem` | @@ -231,17 +222,18 @@ owner 返回全部 18 个 permission id;member 返回数据库中的实时规 | `POST` | `/admin/api/profile/recharge-refunds/execute` | `recharge-orders` | | `POST` | `/admin/api/profile/recharge-refunds/register` | `recharge-orders` | | `POST` | `/admin/api/profile/recharge-refunds/manual-review/resolve` | `recharge-orders` | -| `GET` | `/admin/api/profile/users/detail` | `tables OR tracking OR recharge-orders OR editor-showcase OR editor-assets OR work-visibility` | +| `GET` | `/admin/api/profile/users/detail` | `tables OR tracking OR recharge-orders OR editor-showcase OR editor-assets` | | `POST` | `/admin/api/profile/wallet-restriction` | `recharge-orders` | | `GET` | `/admin/api/accounts` | owner-only | | `POST` | `/admin/api/accounts` | owner-only | | `PUT` | `/admin/api/accounts/{account_id}` | owner-only | -三个共享读取接口必须按 OR 规则实现,不能为了复用简单中间件扩大成“任意 member 可访问”: +两个共享读取接口必须按 OR 规则实现,不能为了复用简单中间件扩大成“任意 member 可访问”: - `/admin/api/assets/read-url` 只服务素材查询和精选审核。 -- `/admin/api/profile/users/detail` 只服务当前实际包含用户详情入口的表查询、埋点数据、充值管理、精选审核、素材查询和作品可见性页面。 -- `GET /admin/api/creation-entry/config` 同时为灰度发布、入口公告和入口开关提供页面初始化数据;写操作仍按具体页面单独收紧。 +- `/admin/api/profile/users/detail` 只服务当前实际包含用户详情入口的表查询、埋点数据、充值管理、精选审核和素材查询页面。 + +`gray-release` 页面只调用 `GET/PUT /admin/api/feature-gates`;旧 `/admin/api/creation-entry/config*` 已退役,不能再作为灰度页面初始化依赖。页面固定目标只登记现役功能,其他通用 gate 仍可通过 Gate Key 直接管理。 ## 10. 账号管理 HTTP 契约 @@ -303,7 +295,7 @@ accounts: Array<{ ### 11.1 路由与导航 -- `adminRoutes` 增加权限元数据;18 个业务路由使用同名 permission id。 +- `adminRoutes` 增加权限元数据;15 个业务路由使用同名 permission id。 - `accounts` 路由只在 `admin.accountRole === "owner"` 时加入侧栏和移动底栏,不属于 member 可分配列表。 - member 导航只渲染 `admin.tabPermissions` 包含的业务路由。页面组件也必须只在当前路由已授权时挂载,避免隐藏导航后仍发起无权限 API。 - owner 渲染全部业务路由和账号管理路由。 @@ -315,13 +307,13 @@ accounts: Array<{ 1. 当前 hash 对应可访问路由时保持不变。 2. hash 未知、属于无权限业务 Tab,或 member 访问 `#accounts` 时,使用 `replaceState` 回落到按第 4 节顺序找到的第一可访问业务 Tab。 3. member 权限为空时,不回落 Dashboard;渲染独立的零权限空态,只保留账号信息和退出登录,不挂载任何业务页,也不发起业务 API。 -4. owner 的默认项仍可保持 Dashboard;账号管理不改变 18 个业务路由的排序。 +4. owner 的默认项仍可保持 Dashboard;账号管理不改变 15 个业务路由的排序。 后端返回 `403` 时,前端重新请求 `/me` 获取实时权限并执行上述回落。即使前端状态陈旧或被篡改,后端权限 middleware 仍必须拒绝越权请求。 ### 11.3 账号管理页 -- 权限编辑器展示 18 个明确的 checkbox,每项使用现有 Tab 中文名称;不能展示或提交 `accounts`。 +- 权限编辑器展示 15 个明确的 checkbox,每项使用现有 Tab 中文名称;不能展示或提交 `accounts`。 - 创建和编辑使用独立弹窗或抽屉,不在列表下方追加表单。 - 编辑时密码字段默认空,空表示请求中省略 `password`;页面永不展示现有密码或 hash。 - 停用使用开关并二次确认。保存成功后以 API 返回 account snapshot 更新列表。 @@ -391,12 +383,12 @@ spacetime publish \ - 权限、密码、启停更新各自会递增 `token_version`;同一次请求修改多项只递增一次;仅改展示名不递增。 - member 被停用、改密或改权限后,旧 JWT 下一次请求返回 401;重新登录后获得实时权限。 - API-to-Tab 矩阵逐路由覆盖 `modules/admin.rs`,每条路由至少测试 owner 成功、具备权限的 member 成功、缺权限 member 返回 403。 -- 三个共享读取接口分别覆盖每个允许 permission 的成功用例,以及无关 permission 的 403 用例。 +- 两个共享读取接口分别覆盖每个允许 permission 的成功用例,以及无关 permission 的 403 用例。 - owner-only 账号 API 对任意 member 都返回 403,即使其 `permissions_json` 被污染为包含 `accounts`。 ### 14.2 前端 -- owner 看到 18 个业务 Tab 和账号管理;member 只看到被分配的业务 Tab。 +- owner 看到 15 个业务 Tab 和账号管理;member 只看到被分配的业务 Tab。 - 每个一级 Tab 内的二级 Tab、弹窗和写操作继承一级权限并正常使用,不出现“页面可见但内部 API 403”的错误映射。 - 直接输入无权限 hash 自动替换为第一可访问项,不短暂挂载无权限页面。 - 当前 Tab 权限被 owner 收回后,下一请求触发重新登录;新会话恢复后落到第一可访问项。 diff --git a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md index de40cc649..fa81d9c6a 100644 --- a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md +++ b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md @@ -25,6 +25,7 @@ - `migration.rs` 中相关表的迁移白名单、表名兼容和字段目录。 - 为历史审计、迁移、资产归属核对所必需的最小只读表定义;不得借兼容读取重新暴露旧创作、发布、公开详情或运行接口。 - 编辑器、项目、账号、钱包、资产、HostBridge、运维和安全等平台公共能力。 +- 通用 `feature_gate_config`、`GET/PUT /admin/api/feature-gates` 与后台 `#gray-release` 控制页。灰度页只读取通用 gate,不再请求旧 `/admin/api/creation-entry/config`,固定目标只登记现役功能;不得恢复 `creation-entry:*` 动态目标。 - 新版 `/creation` 创作工具主页、`/project` 项目入口、稳定的 `/profile` 个人页路由、`creation-home` 展示组件与现役静态资产。桌面端保留“创作 / 项目 / 我的”公共侧边栏,移动端保留同样三项的底部 dock;“我的”保留头像 / 昵称编辑、陶泥号复制、钱包与账单、统计、充值、兑换码、玩家社区、反馈、通用设置、开发者 API Key 和法律信息,不恢复旧模板入口、旧作品架或生成队列。 - `runtime_setting` 是账号级公共设置事实,不属于旧模板运行态。原表结构和数据不变,继续由鉴权后的 `GET/PUT /api/runtime/settings`、`get_runtime_setting_or_default` 与 `upsert_runtime_setting_and_return` procedure 支撑音乐音量和平台主题读写。 - 旧页面、测试、素材、handler、service、worker、生成 bindings 和纯业务 crate 的源码目录;它们仅用于历史追溯,不属于任何正式入口或编译目标。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 7d98b96cf..65f9c6f32 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -59,6 +59,7 @@ npm run check:server-rs-ddd - 健康检查:`GET /healthz`、`GET /readyz`。 - 后台管理:`/admin/api/*`,现役路由包括登录与账号管理、Dashboard / 概览、HTTP debug、埋点、表查询、通用 feature gate、编辑器定价与素材 / 精选管理,以及账号侧兑换码、邀请码、任务、钱包、充值与退款管理;不再挂载旧创作入口配置、旧作品互动或旧玩法运营路由。环境变量管理员固定作为 owner,持久化 member 每次请求按当前 `enabled`、`token_version` 和一级 Tab 权限实时校验;账号管理仅 owner 可访问,未登记权限映射的新后台路由对 member 默认拒绝。完整权限矩阵见 [`docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md`](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md),Dashboard 指标口径见 [`docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。 +- 通用灰度控制面固定为后台 `#gray-release` 与 `GET/PUT /admin/api/feature-gates`;页面固定目标只登记现役功能,不读取旧 `/admin/api/creation-entry/config`,也不恢复 `creation-entry:*` 动态目标。 - 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。 - 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。 - 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。 diff --git a/vitest.config.ts b/vitest.config.ts index f3be028ad..3cea4602d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -57,7 +57,6 @@ export default defineConfig({ ], exclude: [ 'apps/admin-web/src/pages/AdminCreationEntrySwitchPage.test.tsx', - 'apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx', 'scripts/loadtest/**', ], },