From bddc25b4e0ec87b62b2048adc5ff30c3f7bbaaa2 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 14 Jul 2026 19:18:54 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E8=B4=A6=E5=8F=B7=E4=B8=8E=E9=A1=B5=E9=9D=A2=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留环境变量管理员作为 owner,并新增可持久化管理的 member 账号 建立一级页面权限、实时会话失效和后端逐请求鉴权 统一操作记录与配置记录展示管理员显示名称,隐藏内部主体标识 补充账号管理页面、生成绑定、定向测试和技术文档 --- apps/admin-web/src/api/adminApiClient.test.ts | 45 ++ apps/admin-web/src/api/adminApiClient.ts | 31 + apps/admin-web/src/api/adminApiTypes.ts | 50 ++ apps/admin-web/src/app/AdminApp.tsx | 87 ++- apps/admin-web/src/app/AdminShell.tsx | 15 +- apps/admin-web/src/app/adminRoutes.test.ts | 41 +- apps/admin-web/src/app/adminRoutes.ts | 37 +- .../components/AdminUserDetailDialog.test.tsx | 4 + .../src/components/AdminUserDetailDialog.tsx | 2 +- .../admin-web/src/pages/AdminAccountsPage.tsx | 305 ++++++++++ .../src/pages/AdminInviteCodePage.test.tsx | 41 ++ .../src/pages/AdminInviteCodePage.tsx | 2 +- .../pages/AdminProfileWalletConfigPage.tsx | 2 +- .../src/pages/AdminRedeemCodePage.test.tsx | 19 +- .../src/pages/AdminRedeemCodePage.tsx | 2 +- .../src/pages/AdminTaskConfigPage.tsx | 2 +- apps/admin-web/src/styles/admin.css | 104 ++++ docs/README.md | 2 +- .../shared-memory/decision-log.md | 8 + ...管理】多账号与Tab访问权限方案-2026-07-14.md | 428 +++++++++++++ ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 9 +- server-rs/crates/api-server/src/admin.rs | 440 +++++++++++++- .../crates/api-server/src/admin_accounts.rs | 282 +++++++++ .../crates/api-server/src/admin_recharge.rs | 27 +- server-rs/crates/api-server/src/main.rs | 1 + .../crates/api-server/src/modules/admin.rs | 17 + .../crates/api-server/src/runtime_profile.rs | 89 ++- server-rs/crates/api-server/src/state.rs | 93 ++- .../crates/shared-contracts/src/admin.rs | 88 +++ .../crates/shared-contracts/src/runtime.rs | 5 + .../tests/profile_task_contract.rs | 2 + .../spacetime-client/src/admin_account.rs | 106 ++++ server-rs/crates/spacetime-client/src/lib.rs | 2 + .../crates/spacetime-client/src/mapper.rs | 7 + .../src/mapper/admin_account.rs | 143 +++++ .../spacetime-client/src/module_bindings.rs | 47 ++ .../admin_account_create_input_type.rs | 21 + .../admin_account_get_by_id_input_type.rs | 15 + ...dmin_account_get_by_username_input_type.rs | 15 + .../admin_account_procedure_result_type.rs | 20 + .../admin_account_snapshot_type.rs | 25 + .../module_bindings/admin_account_table.rs | 190 ++++++ .../src/module_bindings/admin_account_type.rs | 84 +++ .../admin_account_update_input_type.rs | 20 + ...eate_admin_account_and_return_procedure.rs | 59 ++ ...dmin_account_by_id_and_return_procedure.rs | 59 ++ ...ccount_by_username_and_return_procedure.rs | 59 ++ ...ist_admin_accounts_and_return_procedure.rs | 54 ++ ...date_admin_account_and_return_procedure.rs | 59 ++ .../src/admin_account_storage.rs | 562 ++++++++++++++++++ server-rs/crates/spacetime-module/src/lib.rs | 2 + .../crates/spacetime-module/src/migration.rs | 1 + 52 files changed, 3742 insertions(+), 88 deletions(-) create mode 100644 apps/admin-web/src/pages/AdminAccountsPage.tsx create mode 100644 apps/admin-web/src/pages/AdminInviteCodePage.test.tsx create mode 100644 docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md create mode 100644 server-rs/crates/api-server/src/admin_accounts.rs create mode 100644 server-rs/crates/spacetime-client/src/admin_account.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/admin_account.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_account_create_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_account_get_by_id_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_account_get_by_username_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_account_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_account_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_account_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_account_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_account_update_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/create_admin_account_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_admin_account_by_id_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_admin_account_by_username_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_admin_accounts_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/update_admin_account_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-module/src/admin_account_storage.rs diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index a673e0e01..85f029e37 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -1,16 +1,61 @@ import { afterEach, expect, test, vi } from 'vitest'; import { + createAdminAccount, executeAdminRechargeRefund, getAdminUserDetail, listAdminRechargeOrders, resolveAdminRechargeRefundManualReview, + updateAdminAccount, } from './adminApiClient'; afterEach(() => { vi.unstubAllGlobals(); }); +test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({account: {accountId: 'member-1'}}), { + status: 200, + }), + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await createAdminAccount('owner-token', { + username: 'operator', + displayName: '运营', + password: 'secret123', + tabPermissions: ['dashboard', 'tracking'], + enabled: true, + }); + await updateAdminAccount('owner-token', 'member/1', { + displayName: '运营二组', + tabPermissions: ['tracking'], + enabled: false, + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('/admin/api/accounts'); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({Authorization: 'Bearer owner-token'}), + }), + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1'); + expect(fetchMock.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ + displayName: '运营二组', + tabPermissions: ['tracking'], + enabled: false, + }), + }), + ); +}); + test('充值订单查询按后台契约序列化筛选参数', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ entries: [] }), { diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 89a9bf649..73540117a 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -1,4 +1,7 @@ import type { + AdminAccountListResponse, + AdminCreateAccountRequest, + AdminCreateAccountResponse, AdminUpsertCreationEntryEventBannersRequest, AdminUpsertCreationEntryTypeConfigRequest, AdminCreationEntryConfigResponse, @@ -39,6 +42,8 @@ import type { AdminTrackingEventListResponse, AdminUpdateWorkVisibilityRequest, AdminUpdateWorkVisibilityResponse, + AdminUpdateAccountRequest, + AdminUpdateAccountResponse, AdminUploadedEditorShowcaseCampaignImage, AdminUpsertEditorShowcaseCampaignRequest, AdminUpsertFeatureGateConfigRequest, @@ -188,6 +193,32 @@ export function getAdminMe(token: string) { return request('/admin/api/me', { token }); } +export function listAdminAccounts(token: string) { + return request('/admin/api/accounts', {token}); +} + +export function createAdminAccount( + token: string, + payload: AdminCreateAccountRequest, +) { + return request('/admin/api/accounts', { + method: 'POST', + token, + body: payload, + }); +} + +export function updateAdminAccount( + token: string, + accountId: string, + payload: AdminUpdateAccountRequest, +) { + return request( + `/admin/api/accounts/${encodeURIComponent(accountId)}`, + {method: 'PUT', token, body: payload}, + ); +} + export function getAdminOverview(token: string) { return request('/admin/api/overview', { token }); } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 31afc2d78..abe743139 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -36,10 +36,53 @@ export interface AdminSessionPayload { username: string; displayName: string; roles: string[]; + accountRole: 'owner' | 'member'; + tabPermissions: string[]; issuedAt: string; expiresAt: string; } +export interface AdminAccountPayload { + accountId: string; + username: string; + displayName: string; + accountRole: 'owner' | 'member'; + tabPermissions: string[]; + enabled: boolean; + tokenVersion: number; + createdBy: string; + updatedBy: string; + createdAt: string; + updatedAt: string; +} + +export interface AdminAccountListResponse { + accounts: AdminAccountPayload[]; +} + +export interface AdminCreateAccountRequest { + username: string; + displayName: string; + password: string; + tabPermissions: string[]; + enabled: boolean; +} + +export interface AdminCreateAccountResponse { + account: AdminAccountPayload; +} + +export interface AdminUpdateAccountRequest { + displayName: string; + password?: string; + tabPermissions: string[]; + enabled: boolean; +} + +export interface AdminUpdateAccountResponse { + account: AdminAccountPayload; +} + export interface AdminLoginResponse { token: string; admin: AdminSessionPayload; @@ -629,6 +672,7 @@ export interface ProfileCodeOperationAdminResponse { code: string; action: 'create' | 'update' | 'disable' | string; operatorUserId: string; + operatorDisplayName: string; createdAt: string; } @@ -665,8 +709,10 @@ export interface ProfileTaskConfigAdminResponse { enabled: boolean; sortOrder: number; createdBy: string; + createdByDisplayName: string; createdAt: string; updatedBy: string; + updatedByDisplayName: string; updatedAt: string; } @@ -705,8 +751,10 @@ export interface ProfileWalletConfigAdminResponse { configId: string; initialMudPoints: number; createdBy: string; + createdByDisplayName: string; createdAt: string; updatedBy: string; + updatedByDisplayName: string; updatedAt: string; } @@ -762,8 +810,10 @@ export interface AdminWalletManualRestrictionPayload { frozen: boolean; reason: string; createdByAdminUserId: string; + createdByAdminDisplayName: string; createdAtMicros: number; updatedByAdminUserId: string; + updatedByAdminDisplayName: string; updatedAtMicros: number; } diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 9a596528c..2e58ac6f8 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useState} from 'react'; +import {useCallback, useEffect, useMemo, useState} from 'react'; import { formatAdminApiError, @@ -18,6 +18,7 @@ import { setStoredAdminToken, } from '../auth/adminAuthStore'; import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage'; +import {AdminAccountsPage} from '../pages/AdminAccountsPage'; import {AdminDashboardPage} from '../pages/AdminDashboardPage'; import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage'; import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage'; @@ -37,7 +38,12 @@ import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage'; import {AdminWorkVisibilityPage} from '../pages/AdminWorkVisibilityPage'; import {AdminShell} from './AdminShell'; import type {AdminRouteId} from './adminRoutes'; -import {resolveAdminRoute, routeHash} from './adminRoutes'; +import { + getAccessibleAdminRoutes, + resolveAccessibleAdminRoute, + resolveAdminRoute, + routeHash, +} from './adminRoutes'; type SessionStatus = 'checking' | 'guest' | 'authenticated'; @@ -55,6 +61,13 @@ export function AdminApp() { useState(null); const [rechargeProductResult, setRechargeProductResult] = useState(null); + const accessibleRoutes = useMemo( + () => (admin ? getAccessibleAdminRoutes(admin) : []), + [admin], + ); + const activeRouteId = accessibleRoutes.some((route) => route.id === routeId) + ? routeId + : null; const clearSession = useCallback((message = '') => { clearStoredAdminToken(); @@ -107,6 +120,26 @@ export function AdminApp() { }; }, []); + useEffect(() => { + if (status !== 'authenticated' || !admin) { + return; + } + + const nextRouteId = resolveAccessibleAdminRoute( + window.location.hash, + accessibleRoutes, + ); + if (!nextRouteId) { + return; + } + + setRouteId(nextRouteId); + const nextHash = routeHash(nextRouteId); + if (window.location.hash !== nextHash) { + window.history.replaceState(null, '', nextHash); + } + }, [accessibleRoutes, admin, routeId, status]); + useEffect(() => { const handleHashChange = () => { setRouteId(resolveAdminRoute(window.location.hash)); @@ -163,69 +196,75 @@ export function AdminApp() { return ( - {routeId === 'dashboard' ? ( + {activeRouteId === null ? ( +
+

暂无访问权限

+
+ ) : null} + {activeRouteId === 'dashboard' ? ( ) : null} - {routeId === 'overview' ? ( + {activeRouteId === 'overview' ? ( ) : null} - {routeId === 'tables' ? ( + {activeRouteId === 'tables' ? ( ) : null} - {routeId === 'debug' ? ( + {activeRouteId === 'debug' ? ( ) : null} - {routeId === 'tracking' ? ( + {activeRouteId === 'tracking' ? ( ) : null} - {routeId === 'gray-release' ? ( + {activeRouteId === 'gray-release' ? ( ) : null} - {routeId === 'redeem' ? ( + {activeRouteId === 'redeem' ? ( ) : null} - {routeId === 'invite' ? ( + {activeRouteId === 'invite' ? ( ) : null} - {routeId === 'creation-announcement' ? ( + {activeRouteId === 'creation-announcement' ? ( ) : null} - {routeId === 'creation-entry' ? ( + {activeRouteId === 'creation-entry' ? ( ) : null} - {routeId === 'work-visibility' ? ( + {activeRouteId === 'work-visibility' ? ( ) : null} - {routeId === 'tasks' ? ( + {activeRouteId === 'tasks' ? ( ) : null} - {routeId === 'profile-wallet' ? ( + {activeRouteId === 'profile-wallet' ? ( ) : null} - {routeId === 'recharge-products' ? ( + {activeRouteId === 'recharge-products' ? ( ) : null} - {routeId === 'recharge-orders' ? ( + {activeRouteId === 'recharge-orders' ? ( ) : null} - {routeId === 'editor-generation-pricing' ? ( + {activeRouteId === 'editor-generation-pricing' ? ( ) : null} - {routeId === 'editor-showcase' ? ( + {activeRouteId === 'editor-showcase' ? ( ) : null} - {routeId === 'editor-assets' ? ( + {activeRouteId === 'editor-assets' ? ( ) : null} + {activeRouteId === 'accounts' ? ( + + ) : null}
); } diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index 557b1f42a..12c050fab 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -19,16 +19,17 @@ import { TicketCheck, TicketPercent, ReceiptText, + Users, } from 'lucide-react'; import type {ReactNode} from 'react'; import type {AdminSessionPayload} from '../api/adminApiTypes'; -import type {AdminRouteId} from './adminRoutes'; -import {adminRoutes} from './adminRoutes'; +import type {AdminRouteDefinition, AdminRouteId} from './adminRoutes'; interface AdminShellProps { admin: AdminSessionPayload; - routeId: AdminRouteId; + routeId: AdminRouteId | null; + routes: AdminRouteDefinition[]; children: ReactNode; onRouteChange: (routeId: AdminRouteId) => void; onLogout: () => void; @@ -53,11 +54,13 @@ const routeIcons = { 'creation-announcement': Megaphone, 'creation-entry': SlidersHorizontal, 'work-visibility': Eye, + accounts: Users, } satisfies Record; export function AdminShell({ admin, routeId, + routes, children, onRouteChange, onLogout, @@ -76,7 +79,7 @@ export function AdminShell({