Merge branch 'master' into editor-agent-refactored

This commit is contained in:
2026-07-14 20:59:02 +08:00
59 changed files with 4591 additions and 104 deletions
@@ -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: [] }), {
+31
View File
@@ -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<AdminMeResponse>('/admin/api/me', { token });
}
export function listAdminAccounts(token: string) {
return request<AdminAccountListResponse>('/admin/api/accounts', {token});
}
export function createAdminAccount(
token: string,
payload: AdminCreateAccountRequest,
) {
return request<AdminCreateAccountResponse>('/admin/api/accounts', {
method: 'POST',
token,
body: payload,
});
}
export function updateAdminAccount(
token: string,
accountId: string,
payload: AdminUpdateAccountRequest,
) {
return request<AdminUpdateAccountResponse>(
`/admin/api/accounts/${encodeURIComponent(accountId)}`,
{method: 'PUT', token, body: payload},
);
}
export function getAdminOverview(token: string) {
return request<AdminOverviewResponse>('/admin/api/overview', { token });
}
+50
View File
@@ -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;
}
+66 -21
View File
@@ -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<ProfileWalletConfigAdminResponse | null>(null);
const [rechargeProductResult, setRechargeProductResult] =
useState<ProfileRechargeProductConfigAdminResponse | null>(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 (
<AdminShell
admin={admin}
routeId={routeId}
routeId={activeRouteId}
routes={accessibleRoutes}
onLogout={handleLogout}
onRouteChange={handleRouteChange}
>
{routeId === 'dashboard' ? (
{activeRouteId === null ? (
<section className="admin-panel admin-zero-permission-state">
<h2>访</h2>
</section>
) : null}
{activeRouteId === 'dashboard' ? (
<AdminDashboardPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'overview' ? (
{activeRouteId === 'overview' ? (
<AdminOverviewPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'tables' ? (
{activeRouteId === 'tables' ? (
<AdminDatabaseTablesPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'debug' ? (
{activeRouteId === 'debug' ? (
<AdminDebugHttpPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'tracking' ? (
{activeRouteId === 'tracking' ? (
<AdminTrackingEventsPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'gray-release' ? (
{activeRouteId === 'gray-release' ? (
<AdminGrayReleaseConfigPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'redeem' ? (
{activeRouteId === 'redeem' ? (
<AdminRedeemCodePage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'invite' ? (
{activeRouteId === 'invite' ? (
<AdminInviteCodePage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'creation-announcement' ? (
{activeRouteId === 'creation-announcement' ? (
<AdminCreationEntrySwitchPage
mode="announcements"
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'creation-entry' ? (
{activeRouteId === 'creation-entry' ? (
<AdminCreationEntrySwitchPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'work-visibility' ? (
{activeRouteId === 'work-visibility' ? (
<AdminWorkVisibilityPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'tasks' ? (
{activeRouteId === 'tasks' ? (
<AdminTaskConfigPage
result={taskConfigResult}
token={token}
@@ -233,7 +272,7 @@ export function AdminApp() {
onResultChange={setTaskConfigResult}
/>
) : null}
{routeId === 'profile-wallet' ? (
{activeRouteId === 'profile-wallet' ? (
<AdminProfileWalletConfigPage
result={profileWalletConfigResult}
token={token}
@@ -241,7 +280,7 @@ export function AdminApp() {
onResultChange={setProfileWalletConfigResult}
/>
) : null}
{routeId === 'recharge-products' ? (
{activeRouteId === 'recharge-products' ? (
<AdminRechargeProductPage
result={rechargeProductResult}
token={token}
@@ -249,30 +288,36 @@ export function AdminApp() {
onResultChange={setRechargeProductResult}
/>
) : null}
{routeId === 'recharge-orders' ? (
{activeRouteId === 'recharge-orders' ? (
<AdminRechargeOrderPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'editor-generation-pricing' ? (
{activeRouteId === 'editor-generation-pricing' ? (
<AdminEditorGenerationPricingPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'editor-showcase' ? (
{activeRouteId === 'editor-showcase' ? (
<AdminEditorShowcaseReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'editor-assets' ? (
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'accounts' ? (
<AdminAccountsPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
</AdminShell>
);
}
+9 -6
View File
@@ -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<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
admin,
routeId,
routes,
children,
onRouteChange,
onLogout,
@@ -76,7 +79,7 @@ export function AdminShell({
</div>
<nav className="admin-nav" aria-label="后台导航">
{adminRoutes.map((route) => {
{routes.map((route) => {
const Icon = routeIcons[route.id];
return (
<button
@@ -99,7 +102,7 @@ export function AdminShell({
<header className="admin-topbar">
<div className="admin-user">
<span>{admin.displayName || admin.username}</span>
<small>{admin.roles.join(' / ')}</small>
<small>{admin.accountRole === 'owner' ? 'owner' : 'member'}</small>
</div>
<button
className="admin-icon-button"
@@ -116,7 +119,7 @@ export function AdminShell({
</div>
<nav className="admin-bottom-nav" aria-label="后台导航">
{adminRoutes.map((route) => {
{routes.map((route) => {
const Icon = routeIcons[route.id];
return (
<button
+40 -1
View File
@@ -1,6 +1,12 @@
import {expect, test} from 'vitest';
import {adminRoutes, resolveAdminRoute, routeHash} from './adminRoutes';
import {
adminRoutes,
getAccessibleAdminRoutes,
resolveAccessibleAdminRoute,
resolveAdminRoute,
routeHash,
} from './adminRoutes';
test('后台默认进入 Dashboard', () => {
expect(adminRoutes[0]).toEqual({
@@ -79,3 +85,36 @@ test('后台充值管理路由可通过导航和 hash 访问', () => {
expect(resolveAdminRoute('#recharge-orders')).toBe('recharge-orders');
expect(routeHash('recharge-orders')).toBe('#recharge-orders');
});
test('owner 可访问全部业务 Tab 和账号管理', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'owner',
tabPermissions: [],
});
expect(routes).toEqual(adminRoutes);
expect(routes.at(-1)).toMatchObject({id: 'accounts', ownerOnly: true});
});
test('member 只访问已分配 Tab 且无权 hash 回落到第一项', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['tracking', 'recharge-orders'],
});
expect(routes.map((route) => route.id)).toEqual([
'tracking',
'recharge-orders',
]);
expect(resolveAccessibleAdminRoute('#accounts', routes)).toBe('tracking');
expect(resolveAccessibleAdminRoute('#recharge-orders', routes)).toBe(
'recharge-orders',
);
});
test('零权限 member 不回落到 Dashboard', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: [],
});
expect(routes).toEqual([]);
expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull();
});
+36 -1
View File
@@ -17,13 +17,17 @@ export type AdminRouteId =
| 'editor-assets'
| 'creation-announcement'
| 'creation-entry'
| 'work-visibility';
| 'work-visibility'
| 'accounts';
export type AdminTabPermission = Exclude<AdminRouteId, 'accounts'>;
/** 后台导航项定义,hash 是浏览器地址栏和移动底栏共用入口。 */
export interface AdminRouteDefinition {
id: AdminRouteId;
label: string;
hash: string;
ownerOnly?: boolean;
}
export const adminRoutes: AdminRouteDefinition[] = [
@@ -45,8 +49,39 @@ export const adminRoutes: AdminRouteDefinition[] = [
{id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'},
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
{id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true},
];
export interface AdminRouteAccess {
accountRole: 'owner' | 'member';
tabPermissions: string[];
}
export function getAccessibleAdminRoutes(
admin: AdminRouteAccess,
): AdminRouteDefinition[] {
if (admin.accountRole === 'owner') {
return adminRoutes;
}
const permissions = new Set(admin.tabPermissions);
return adminRoutes.filter(
(route) => !route.ownerOnly && permissions.has(route.id),
);
}
export function resolveAccessibleAdminRoute(
hash: string,
routes: AdminRouteDefinition[],
): AdminRouteId | null {
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
return (
routes.find((route) => route.hash === normalizedHash)?.id ??
routes[0]?.id ??
null
);
}
/** 根据地址栏 hash 解析后台路由,未知 hash 回落到 Dashboard。 */
export function resolveAdminRoute(hash: string): AdminRouteId {
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
@@ -146,8 +146,10 @@ test('人工冻结和解除人工冻结分别提交原因且不解除退款欠
frozen: true,
reason: '风险核查',
createdByAdminUserId: 'admin:root',
createdByAdminDisplayName: '后台负责人',
createdAtMicros: 1_720_000_000_000_000,
updatedByAdminUserId: 'admin:root',
updatedByAdminDisplayName: '后台负责人',
updatedAtMicros: 1_720_000_000_000_000,
},
};
@@ -174,6 +176,8 @@ test('人工冻结和解除人工冻结分别提交原因且不解除退款欠
reason: '异常登录',
});
});
expect(await screen.findByText(/后台负责人/)).toBeTruthy();
expect(screen.queryByText(/admin:root/)).toBeNull();
expect(await screen.findByText('解除人工冻结后,退款欠账限制仍会保留。')).toBeTruthy();
await user.type(screen.getByRole('textbox', {name: '人工冻结操作原因'}), '核查完成');
@@ -236,7 +236,7 @@ export function AdminUserDetailDialog({
<span>{detail.wallet.manualRestriction.reason || '未填写原因'}</span>
<small>
{formatMicros(detail.wallet.manualRestriction.updatedAtMicros)} /{' '}
{detail.wallet.manualRestriction.updatedByAdminUserId}
{detail.wallet.manualRestriction.updatedByAdminDisplayName}
</small>
</div>
) : null}
@@ -0,0 +1,305 @@
import {Plus, RefreshCcw, Save} from 'lucide-react';
import {type FormEvent, useEffect, useState} from 'react';
import {
createAdminAccount,
listAdminAccounts,
updateAdminAccount,
} from '../api/adminApiClient';
import type {AdminAccountPayload} from '../api/adminApiTypes';
import {adminRoutes} from '../app/adminRoutes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
interface AdminAccountsPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const assignableRoutes = adminRoutes.filter((route) => !route.ownerOnly);
export function AdminAccountsPage({
token,
onUnauthorized,
}: AdminAccountsPageProps) {
const [accounts, setAccounts] = useState<AdminAccountPayload[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState('');
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [enabled, setEnabled] = useState(true);
const [tabPermissions, setTabPermissions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
useEffect(() => {
void refreshAccounts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshAccounts() {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminAccounts(token);
setAccounts(response.accounts);
const selected = response.accounts.find(
(account) => account.accountId === selectedAccountId,
);
if (selected) {
fillForm(selected);
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}
function startCreate() {
setSelectedAccountId('');
setUsername('');
setDisplayName('');
setPassword('');
setEnabled(true);
setTabPermissions([]);
setErrorMessage('');
}
function fillForm(account: AdminAccountPayload) {
setSelectedAccountId(account.accountId);
setUsername(account.username);
setDisplayName(account.displayName);
setPassword('');
setEnabled(account.enabled);
setTabPermissions(account.tabPermissions);
setErrorMessage('');
}
function togglePermission(permission: string, checked: boolean) {
setTabPermissions((current) =>
checked
? assignableRoutes
.map((route) => route.id)
.filter((routeId) =>
routeId === permission || current.includes(routeId),
)
: current.filter((item) => item !== permission),
);
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
const normalizedUsername = username.trim();
const normalizedDisplayName = displayName.trim();
if (!selectedAccountId && !normalizedUsername) {
setErrorMessage('请输入用户名');
return;
}
if (!normalizedDisplayName) {
setErrorMessage('请输入显示名称');
return;
}
if (!selectedAccountId && !password) {
setErrorMessage('请输入密码');
return;
}
const confirmed = await confirmWrite({
action: selectedAccountId ? '更新后台账号' : '创建后台账号',
target: normalizedUsername,
});
if (!confirmed) {
return;
}
setIsSaving(true);
setErrorMessage('');
try {
const response = selectedAccountId
? await updateAdminAccount(token, selectedAccountId, {
displayName: normalizedDisplayName,
...(password ? {password} : {}),
tabPermissions,
enabled,
})
: await createAdminAccount(token, {
username: normalizedUsername,
displayName: normalizedDisplayName,
password,
tabPermissions,
enabled,
});
setAccounts((current) => {
const rest = current.filter(
(account) => account.accountId !== response.account.accountId,
);
return [...rest, response.account].sort((left, right) =>
left.username.localeCompare(right.username),
);
});
fillForm(response.account);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
<div className="admin-action-row">
<button
className="admin-secondary-button"
type="button"
onClick={startCreate}
>
<Plus size={17} aria-hidden="true" />
<span></span>
</button>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshAccounts}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
</div>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<div className="admin-accounts-layout">
<section className="admin-panel admin-account-list">
<div className="admin-panel-heading">
<h3></h3>
<span>{accounts.length}</span>
</div>
{accounts.length ? (
<div className="admin-account-list-items">
{accounts.map((account) => (
<button
data-active={account.accountId === selectedAccountId}
disabled={account.accountRole === 'owner'}
key={account.accountId}
title={account.accountRole === 'owner' ? 'owner' : account.username}
type="button"
onClick={() => {
if (account.accountRole === 'member') {
fillForm(account);
}
}}
>
<span>
<strong>{account.displayName || account.username}</strong>
<small>{account.username}</small>
</span>
<small>
{account.accountRole === 'owner'
? 'owner'
: account.enabled
? '启用'
: '停用'}
</small>
</button>
))}
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无成员账号'}
</div>
)}
</section>
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-panel-heading">
<h3>{selectedAccountId ? '编辑账号' : '添加账号'}</h3>
<label className="admin-switch-field">
<input
checked={enabled}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span></span>
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
disabled={Boolean(selectedAccountId)}
autoComplete="off"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
</label>
</div>
<label className="admin-field">
<span>{selectedAccountId ? '新密码' : '密码'}</span>
<input
autoComplete="new-password"
placeholder={selectedAccountId ? '不修改' : ''}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<fieldset className="admin-permission-fieldset">
<legend>Tab 访</legend>
<div className="admin-permission-grid">
{assignableRoutes.map((route) => (
<label key={route.id}>
<input
checked={tabPermissions.includes(route.id)}
type="checkbox"
onChange={(event) =>
togglePermission(route.id, event.target.checked)
}
/>
<span>{route.label}</span>
</label>
))}
</div>
</fieldset>
<button
className="admin-primary-button"
disabled={isSaving}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
</div>
{confirmDialog}
</section>
);
}
@@ -0,0 +1,41 @@
/* @vitest-environment jsdom */
import {render, screen} from '@testing-library/react';
import {beforeEach, expect, test, vi} from 'vitest';
import {listProfileInviteCodes} from '../api/adminApiClient';
import {AdminInviteCodePage} from './AdminInviteCodePage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
isAdminApiError: vi.fn(() => false),
listProfileInviteCodes: vi.fn(),
upsertProfileInviteCode: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listProfileInviteCodes).mockResolvedValue({
entries: [],
operations: [
{
operationId: 'operation-1',
codeKind: 'invite',
code: 'TEAM',
action: 'create',
operatorUserId: 'admin-account-internal-1',
operatorDisplayName: '邀请运营',
createdAt: '2026-07-14T10:00:00Z',
},
],
});
});
test('操作记录只展示管理员显示名称', async () => {
render(<AdminInviteCodePage token="admin-token" onUnauthorized={vi.fn()} />);
expect(await screen.findByText('邀请运营')).toBeTruthy();
expect(screen.queryByText('admin-account-internal-1')).toBeNull();
});
@@ -278,7 +278,7 @@ export function AdminInviteCodePage({
<tr key={operation.operationId}>
<td>{operationActionLabel(operation.action)}</td>
<td>{operation.code}</td>
<td>{operation.operatorUserId}</td>
<td>{operation.operatorDisplayName}</td>
<td>{formatDateTime(operation.createdAt)}</td>
</tr>
))}
@@ -149,7 +149,7 @@ export function AdminProfileWalletConfigPage({
</div>
<div>
<dt></dt>
<dd>{result.updatedBy || '-'}</dd>
<dd>{result.updatedByDisplayName || '-'}</dd>
</div>
<div>
<dt></dt>
@@ -68,7 +68,17 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listProfileRedeemCodes).mockResolvedValue({
entries,
operations: [],
operations: [
{
operationId: 'operation-1',
codeKind: 'redeem',
code: 'LONG-LIVED',
action: 'create',
operatorUserId: 'admin-account-internal-1',
operatorDisplayName: '兑换码运营',
createdAt: '2026-07-13T01:00:00Z',
},
],
});
vi.mocked(upsertProfileRedeemCode).mockResolvedValue(baseEntry);
vi.mocked(disableProfileRedeemCode).mockResolvedValue({
@@ -77,6 +87,13 @@ beforeEach(() => {
});
});
test('操作记录只展示管理员显示名称', async () => {
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
expect(await screen.findByText('兑换码运营')).toBeTruthy();
expect(screen.queryByText('admin-account-internal-1')).toBeNull();
});
test('兑换码列表展示生效状态与日期范围', async () => {
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
@@ -402,7 +402,7 @@ export function AdminRedeemCodePage({
<tr key={operation.operationId}>
<td>{operationActionLabel(operation.action)}</td>
<td>{operation.code}</td>
<td>{operation.operatorUserId}</td>
<td>{operation.operatorDisplayName}</td>
<td>{formatDateTime(operation.createdAt)}</td>
</tr>
))}
@@ -553,7 +553,7 @@ export function AdminTaskConfigPage({
</div>
<div>
<dt></dt>
<dd>{result.updatedBy}</dd>
<dd>{result.updatedByDisplayName}</dd>
</div>
<div>
<dt></dt>
+104
View File
@@ -216,6 +216,17 @@ button:disabled {
overflow: auto;
}
.admin-zero-permission-state {
min-height: 180px;
place-items: center;
}
.admin-zero-permission-state h2 {
margin: 0;
color: #6f5848;
font-size: 20px;
}
.admin-page {
display: grid;
gap: 18px;
@@ -513,6 +524,90 @@ button:disabled {
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
}
.admin-accounts-layout {
display: grid;
grid-template-columns: minmax(240px, 0.42fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.admin-account-list {
align-content: start;
}
.admin-account-list-items {
display: grid;
gap: 8px;
}
.admin-account-list-items > button {
display: flex;
min-width: 0;
min-height: 58px;
align-items: center;
justify-content: space-between;
gap: 12px;
border: 1px solid #eaded2;
border-radius: 8px;
color: #755a49;
background: #fffdf9;
padding: 10px 12px;
text-align: left;
}
.admin-account-list-items > button[data-active="true"] {
border-color: #c87955;
background: #f9eee5;
}
.admin-account-list-items > button > span {
display: grid;
min-width: 0;
gap: 3px;
}
.admin-account-list-items strong,
.admin-account-list-items small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-account-list-items small {
color: #8f7868;
font-size: 12px;
}
.admin-permission-fieldset {
min-width: 0;
margin: 0;
border: 1px solid #e1ccbb;
border-radius: 8px;
padding: 14px;
}
.admin-permission-fieldset legend {
color: #6f5848;
font-size: 13px;
font-weight: 750;
padding: 0 6px;
}
.admin-permission-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px 14px;
}
.admin-permission-grid label {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
color: #5f4738;
font-size: 13px;
}
.admin-stack,
.admin-form {
display: grid;
@@ -1861,6 +1956,7 @@ button:disabled {
.admin-dashboard-operations,
.admin-two-column,
.admin-two-column-wide,
.admin-accounts-layout,
.admin-pricing-grid,
.admin-form-row,
.admin-filter-grid,
@@ -1870,6 +1966,10 @@ button:disabled {
grid-template-columns: 1fr;
}
.admin-permission-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-dashboard-heading {
display: grid;
}
@@ -1965,6 +2065,10 @@ button:disabled {
}
@media (max-width: 560px) {
.admin-permission-grid {
grid-template-columns: 1fr;
}
.admin-login-panel,
.admin-panel {
padding: 16px;
+1 -1
View File
@@ -46,7 +46,7 @@ React 组件测试的用户行为、稳定契约、hook / model 分层断言口
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。Pingora 只作为独立二进制影子网关试点时,边界、路由口径与替换前验收见 [【开发运维】Pingora独立网关试点-2026-06-11.md](./technical/【开发运维】Pingora独立网关试点-2026-06-11.md)。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md),Dashboard 默认入口、运营指标和统计口径见 [【后台管理】Dashboard运营看板方案-2026-06-23.md](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。Pingora 只作为独立二进制影子网关试点时,边界、路由口径与替换前验收见 [【开发运维】Pingora独立网关试点-2026-06-11.md](./technical/【开发运维】Pingora独立网关试点-2026-06-11.md)。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md),Dashboard 默认入口、运营指标和统计口径见 [【后台管理】Dashboard运营看板方案-2026-06-23.md](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)owner/member 多账号、18 个一级 Tab 权限和逐请求鉴权方案见 [【后台管理】多账号与Tab访问权限方案-2026-07-14.md](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md)
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
@@ -23,6 +23,14 @@
- 验证方式:`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_agent``cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent``npm run test -- src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx src/services/image-editor/editorAgentClient.test.ts``npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md``docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md`
## 2026-07-14 后台账号采用 owner 引导账号与一级 Tab 实时授权
- 背景:后台此前只支持一组环境变量管理员,所有 `/admin/api/*` 共用统一 admin 门禁,无法给运营、审核等人员分配独立账号和页面范围。
- 决策:现有 `GENARRATIVE_ADMIN_USERNAME/PASSWORD` 账号固定作为不可编辑 owner;新增 member 独立保存到私有 `admin_account` 表,密码使用 Argon2id 摘要。登录凭据快照与普通账号快照在类型层分离,普通列表、按 ID 查询和写入响应不包含 `password_hash`。Argon2id 在 blocking 任务中运行并由 api-server 有界限流;未知、停用和 owner 错密账号使用 dummy hash 抹平耗时。member 权限粒度固定为后台 18 个一级 Tab,“账号管理”只允许 owner 且不可授予 member。member 每次请求重新读取当前账号并校验启停、`token_version` 和 Tab 权限;权限、密码或启停变化递增版本并立即淘汰旧 JWT。账号不存在返回 `401`SpacetimeDB 故障保留 `502/503` 而不清理有效 token。前端导航过滤和页面挂载门禁只负责体验,正式授权由 api-server 的 API-to-Tab 矩阵执行,未登记的新后台路由对 member 默认拒绝。后台面向运营展示管理员身份时统一使用 `displayName`;持久审计仍保存稳定 subject,由 api-server 解析显示名称,前端不得暴露账号 ID 或用登录用户名代替。写接口必须在主事务前加载显示名目录,或在主事务后降级解析,不能把已提交写入伪装为失败。
- 影响范围:`admin_account`、SpacetimeDB typed procedures / client facade、后台 JWT 与 session DTO、`/admin/api/accounts*`、后台路由权限中间件、admin-web 导航和账号管理页。
- 验证方式:SpacetimeDB schema / client / API 定向测试、`npm run check:admin-account-procedures` 隔离 procedure smoke、完整路由矩阵测试、admin-web 权限路由与账号 API 测试、owner/member 浏览器 smoke、`npm run check:spacetime-schema`、编码与 diff 门禁。
- 关联文档:`docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md`
## 2026-07-13 图片画布生成资源支持提交前统一命名
- 背景:图片画布的普通图片、规范、角色、图标图集、UI 设计、宣发素材、视频和音频默认使用“类型 + 数字”命名,用户只能在生成后单独重命名素材,画布图层、项目资源和素材库名称容易不一致。
@@ -0,0 +1,430 @@
# 后台管理多账号与 Tab 访问权限方案
更新时间:`2026-07-14`
## 1. 文档定位
本文定义陶泥儿后台从单一环境变量管理员扩展为“1 个 owner 引导账号 + 多个 member 持久账号”的编码契约,并为每个一级 Tab 建立前后端一致的访问权限。
本次只增加后台管理员账号与整页访问权限,不引入页面内按钮级、字段级或只读权限。正式实现必须同时完成前端导航过滤和后端 API 鉴权;前端过滤只改善体验,不能作为安全边界。
## 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。
改造后的目标如下:
1. 现有环境变量账号升级为 `owner`,仍由部署环境提供,不迁移、不复制到 SpacetimeDB。
2. owner 始终拥有全部 18 个业务 Tab 权限,并独占“账号管理”Tab 和账号管理 API。
3. owner 可以创建、修改、启停 membermember 保存在 SpacetimeDB 私有表 `admin_account`
4. member 按一级 Tab 分配权限;获得一个 Tab 权限即获得该页面内全部读写能力,页面内部二级 Tab、弹窗和操作区继承一级权限。
5. member JWT 每次请求都重新读取当前账号并校验 `enabled``token_version` 和实时权限,权限、密码或启停变更应立即让旧 JWT 失效。
## 3. 角色与不可变规则
### 3.1 owner
- owner 用户名和密码继续读取 `GENARRATIVE_ADMIN_USERNAME``GENARRATIVE_ADMIN_PASSWORD`
- owner 是环境变量构造的虚拟账号,不写入 `admin_account`,不允许通过后台改名、改密、禁用或删除。
- owner 始终拥有本文列出的全部 18 个可分配权限,不能在前端取消,也不从数据库加载权限。
- “账号管理”是 owner-only 能力。它可以作为新增一级路由 `accounts` / `#accounts` 展示,但 `accounts` 不进入 `ADMIN_TAB_PERMISSIONS`,不能写入 member 的 `permissions_json`
- owner 会话返回 `accountRole = "owner"``roles = ["admin", "owner"]`;账号管理权限必须根据服务端确认的 `accountRole` 判断,不能只相信前端角色字符串。
- owner 配置缺失时,后台整体保持未启用状态;不能依赖数据库中的 member 绕过 owner 引导配置启动后台。
### 3.2 member
- member 只来自 `admin_account`,不新增第二套环境变量账号。
- member 会话返回 `accountRole = "member"``roles = ["admin", "member"]` 和当前实时 `tabPermissions`
- member 永远不能访问账号管理页面或账号管理 API,也不能给自己或他人分配 `accounts`
- member 的一个一级 Tab 权限覆盖该页面的查询、创建、修改、启停、退款等全部现有操作,不拆成 `read` / `write`
- 页面内二级 Tab、筛选视图、抽屉、弹窗和共享详情弹窗继承触发它的一级 Tab 权限,不另设 permission id。
## 4. 权限标识
`ADMIN_TAB_PERMISSIONS` 必须是 shared-contracts 与 admin-web 共用的闭合集合,值与现有 `AdminRouteId` 一致。18 个可分配权限如下,顺序同时作为前端寻找“第一可访问项”的稳定顺序:
| permission id | 一级 Tab | hash |
| --- | --- | --- |
| `dashboard` | Dashboard | `#dashboard` |
| `overview` | 服务总览 | `#overview` |
| `tables` | 表查询 | `#tables` |
| `debug` | API 调试 | `#debug` |
| `tracking` | 埋点数据 | `#tracking` |
| `gray-release` | 灰度发布 | `#gray-release` |
| `redeem` | 兑换码 | `#redeem` |
| `invite` | 邀请码 | `#invite` |
| `profile-wallet` | 账号配置 | `#profile-wallet` |
| `tasks` | 任务配置 | `#tasks` |
| `recharge-products` | 充值商品 | `#recharge-products` |
| `recharge-orders` | 充值管理 | `#recharge-orders` |
| `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 可以登录但没有业务页面权限。
后续新增一级 Tab 时,必须在同一次改动中更新:
- shared-contracts 的 `ADMIN_TAB_PERMISSIONS`
- admin-web 的路由定义、权限标签和第一可访问项顺序。
- 本文 API-to-Tab 权限矩阵。
- 后端路由权限测试;没有明确权限映射的新 `/admin/api/*` 路由必须默认拒绝 member,而不是默认放行。
## 5. 数据模型
新增 SpacetimeDB 私有表 `admin_account`。表不能标记 `public`,浏览器不能订阅或直查;所有读写都由 `api-server -> spacetime-client facade -> 受限 procedure` 完成。
| 字段 | Rust / SpacetimeDB 类型 | 约束与语义 |
| --- | --- | --- |
| `account_id` | `String` | 主键;服务端生成不可变 opaque id,建议 `admin-account-<uuid>`,请求体不得指定 |
| `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 个值,空数组为 `[]` |
| `enabled` | `bool` | 是否允许登录和继续使用现有 JWT |
| `token_version` | `u64` | 初始为 `1`;权限、密码或启停状态发生有效变化时加 `1` |
| `created_by` | `String` | 创建者后台 subject;当前只能是 owner subject |
| `updated_by` | `String` | 最近更新者后台 subject;当前只能是 owner subject |
| `created_at` | `Timestamp` | 创建时间,使用 `ctx.timestamp` |
| `updated_at` | `Timestamp` | 最近更新时间,使用 `ctx.timestamp` |
账号规则:
- `username` 建议限制为 3 至 64 个字符,只允许 ASCII 字母、数字、`.``_``-`;规范化后做唯一性校验。
- owner 用户名属于保留名称。创建 member 时必须同时与当前规范化后的 owner 用户名比较并拒绝冲突,不能只依赖 `admin_account.username` 唯一索引。
- 密码明文只存在于登录、创建和改密请求生命周期内;限制为 6 至 128 个字符,并复用 `platform-auth` 的 Argon2id 哈希与校验能力。Argon2id 必须在 blocking 任务中执行,api-server 通过有界信号量限制同时 hash / verify 数量,不得占用 Tokio worker 或无界堆积高成本任务。
- 不提供物理删除 API。离职或停用通过 `enabled = false` 完成,以保留 `created_by``updated_by` 和账号标识。
- `display_name` 单独变化只更新 `updated_by``updated_at`,不要求递增 `token_version`;权限、密码、`enabled` 任一有效变化必须在同一事务中递增版本。
- `u64` 版本到达上限时更新失败关闭,不能回绕。
## 6. SpacetimeDB 与 facade 边界
建议新增 `server-rs/crates/spacetime-module/src/admin_account.rs`,并在 `spacetime-client` 增加对应 admin facade。至少提供以下 typed procedures
| procedure | 用途 | 是否可返回 `password_hash` |
| --- | --- | --- |
| `get_admin_account_by_username_and_return` | member 登录查询 | 是,仅返回给 api-server 内部认证路径 |
| `get_admin_account_by_id_and_return` | member JWT 逐请求校验 | 否 |
| `list_admin_accounts_and_return` | owner 账号列表 | 否 |
| `create_admin_account_and_return` | owner 创建 member | 否 |
| `update_admin_account_and_return` | owner 更新展示名、密码 hash、权限、启停 | 否 |
所有 `admin_account` procedures 都必须在事务入口调用现有 `require_editor_generation_runtime_service_identity(...)` 等价的统一 runtime service identity 守卫,只允许 api-server 当前 runtime service identity 调用。不能因为它们位于后台命名空间就接受任意 SpacetimeDB client identity,也不能新增 public table/view 暴露账号或 hash。
写 procedure 接收由 api-server 从已认证 owner 会话生成的 `actor_subject`,校验非空后写入 `created_by` / `updated_by`。SpacetimeDB 仍以 `ctx.sender()` 校验调用方是 runtime service identity;不能把请求体中的 actor 当成调用身份。
procedure result 使用 typed snapshot,不使用不透明 `row_json`。账号列表 snapshot 明确排除 `password_hash`;登录专用 snapshot 与普通账号 DTO 分离,避免序列化时误回传 hash。
## 7. 登录与 JWT 契约
### 7.1 登录优先级
`POST /admin/api/login` 按下列固定顺序处理:
1. 规范化提交的用户名。
2. 若用户名等于 owner 用户名,直接校验 `GENARRATIVE_ADMIN_PASSWORD`
3. owner 密码不匹配时返回统一的“管理员用户名或密码错误”,不得继续查询同名 member。
4. 用户名不等于 owner 时,按规范化用户名查询 `admin_account`
5. member 不存在、`enabled = false` 或 Argon2id 校验失败时返回同一登录错误,不泄露账号是否存在或被停用。
6. member 登录成功后,用当前 `account_id``token_version` 签发后台 JWT。
owner 优先既保持原账号行为,也防止数据库同名记录遮蔽或降级 owner。密码比较不得写日志;member 必须复用 Argon2id 校验,不能存明文或可逆密文。未知 member、已停用 member 和 owner 错误密码路径仍执行同成本 dummy Argon2id 校验,避免从响应耗时枚举启用账号。
### 7.2 JWT claims 与逐请求校验
后台 JWT 继续使用后台独立 TTL 与签名配置。claims 至少能区分:
- `account_type`: `owner | member`
- owner 的稳定 subject,或 member 的 `account_id` subject。
- `token_version`member 使用表中当前值;owner 使用固定虚拟值,不能由数据库覆盖。
- `roles`:保留 `admin`,并追加 `owner``member`
权限不作为 JWT 中的授权真相。即使为调试在 claims 中携带 permissions,后端也必须忽略它并读取实时账号。
`require_admin_auth` 每次请求都要构造“当前账号”:
1. 验签并校验后台 issuer、过期时间和 `admin` role。
2. owner JWT:与当前环境变量构造的 owner subject 匹配,得到始终启用、全权限的虚拟当前账号;owner 不查 `admin_account`
3. member JWT:按 claim 中 `account_id` 调用 `get_admin_account_by_id_and_return`,账号不存在或 `enabled = false` 时拒绝。只有明确的账号不存在才视为凭据失效;SpacetimeDB 超时、断连或 procedure 故障必须失败关闭但保留 `502/503` 依赖错误语义,不能伪装成 `401` 导致前端清除 token。
4. member claim 的 `token_version` 必须与表中当前值完全一致,否则返回 `401 Unauthorized` 并要求重新登录。
5. 将服务端实时构造的 `AuthenticatedAdmin` 放入 request extensions,后续权限 middleware 只读取该对象,不再相信原始 claims。
权限、密码、启停更新递增 `token_version` 后,目标 member 的所有旧 JWT 从下一次请求开始失效。owner 修改自己的环境变量密码仍通过部署配置和服务重启完成;owner 不入表,因此不使用 member 的 `token_version` 机制。
### 7.3 会话响应
`AdminSessionPayload` 在现有字段基础上增加:
```text
accountRole: "owner" | "member"
tabPermissions: string[]
```
owner 返回全部 18 个 permission idmember 返回数据库中的实时规范化数组。`GET /admin/api/me` 同样执行逐请求校验并返回实时权限,供刷新页面后恢复导航。
后台所有面向运营展示的管理员身份统一使用 `displayName`。审计表继续保存稳定 subject,例如 owner subject 或 `admin-account-<uuid>`api-server 在返回兑换码、邀请码等操作记录时,按 owner 运行态和 `admin_account` 批量解析显示名称,同时兼容历史用户名记录。已无法解析的历史主体统一展示“已停用管理员”,前端不得直接渲染 `operatorUserId`、账号 ID 或登录用户名代替显示名称。对写接口,显示名目录必须在主事务前加载,或在主事务成功后降级为占位文案;不得因二次读取失败把已提交写入伪装成失败。
## 8. 权限中间件与错误语义
在统一 `require_admin_auth` 之后增加可复用的权限守卫,支持:
- `require_admin_permission(permission)`owner 自动通过;member 必须包含该 permission。
- `require_any_admin_permission([permission...])`owner 自动通过;member 至少包含一个,用于共享 API。
- `require_admin_owner`:只接受服务端确认的 owner。
返回语义统一如下:
- `401 Unauthorized`:token 缺失、无效、过期,member 不存在、被停用或 `token_version` 过期。
- `403 Forbidden`:会话有效但缺少目标 Tab 权限,或 member 请求 owner-only API。
- 前端收到 `401` 清除本地 token 并回到登录页;收到 `403` 不应伪装成掉线,应刷新 `/me` 权限并跳转到第一可访问项或零权限空态。
## 9. API-to-Tab 权限矩阵
下表覆盖 `server-rs/crates/api-server/src/modules/admin.rs` 当前全部路由,并追加账号管理 API。`A OR B` 表示 member 拥有任一权限即可;owner 对全部行自动通过。
| Method | 路径 | 权限 |
| --- | --- | --- |
| `POST` | `/admin/api/login` | 公开登录入口,不要求 JWT |
| `GET` | `/admin/api/me` | 任意有效后台会话 |
| `GET` | `/admin/api/overview` | `overview` |
| `GET` | `/admin/api/dashboard` | `dashboard` |
| `POST` | `/admin/api/debug/http` | `debug` |
| `GET` | `/admin/api/tracking/events` | `tracking` |
| `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` |
| `POST` | `/admin/api/editor-generation-pricing` | `editor-generation-pricing` |
| `GET` | `/admin/api/editor-assets` | `editor-assets` |
| `GET` | `/admin/api/assets/read-url` | `editor-assets OR editor-showcase` |
| `GET` | `/admin/api/editor-showcase/assets` | `editor-showcase` |
| `POST` | `/admin/api/editor-showcase/assets/review` | `editor-showcase` |
| `POST` | `/admin/api/editor-showcase/assets/display` | `editor-showcase` |
| `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` |
| `GET` | `/admin/api/profile/invite-codes` | `invite` |
| `POST` | `/admin/api/profile/invite-codes` | `invite` |
| `GET` | `/admin/api/profile/tasks` | `tasks` |
| `POST` | `/admin/api/profile/tasks` | `tasks` |
| `POST` | `/admin/api/profile/tasks/disable` | `tasks` |
| `GET` | `/admin/api/profile/wallet-config` | `profile-wallet` |
| `POST` | `/admin/api/profile/wallet-config` | `profile-wallet` |
| `GET` | `/admin/api/profile/recharge-products` | `recharge-products` |
| `POST` | `/admin/api/profile/recharge-products` | `recharge-products` |
| `GET` | `/admin/api/profile/recharge-orders` | `recharge-orders` |
| `POST` | `/admin/api/profile/recharge-refunds/preview` | `recharge-orders` |
| `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` |
| `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 可访问”:
- `/admin/api/assets/read-url` 只服务素材查询和精选审核。
- `/admin/api/profile/users/detail` 只服务当前实际包含用户详情入口的表查询、埋点数据、充值管理、精选审核、素材查询和作品可见性页面。
- `GET /admin/api/creation-entry/config` 同时为灰度发布、入口公告和入口开关提供页面初始化数据;写操作仍按具体页面单独收紧。
## 10. 账号管理 HTTP 契约
账号管理 API 放在现有 `/admin/api` 命名空间,统一使用现有 success/error envelope。
### 10.1 `GET /admin/api/accounts`
返回:
```text
accounts: Array<{
accountId,
username,
displayName,
tabPermissions,
enabled,
tokenVersion,
createdBy,
updatedBy,
createdAt,
updatedAt
}>
```
首项由 api-server 根据环境变量合成只读 owner 记录,后续 member 按用户名和账号 ID 稳定排序。不得返回 `password``passwordHash`owner 记录必须带 `accountRole = "owner"` 且前后端都拒绝编辑。
### 10.2 `POST /admin/api/accounts`
请求:
```text
{
username: string,
displayName: string,
password: string,
tabPermissions: string[],
enabled?: boolean
}
```
`password` 创建时必填;`enabled` 默认 `true`。api-server 完成用户名、密码和权限校验,使用 Argon2id 生成 hash 后调用创建 procedure。用户名冲突返回 `409 Conflict`;参数非法返回 `400 Bad Request`。响应为不含任何密码字段的 `account`
### 10.3 `PUT /admin/api/accounts/{account_id}`
请求:
```text
{
displayName: string,
password?: string,
tabPermissions: string[],
enabled: boolean
}
```
`username``account_id` 不可修改。更新请求完整提交显示名称、Tab 权限和启停状态;密码省略表示不修改,空字符串密码作为非法参数拒绝。api-server 只在提供新密码时生成新 hash。procedure 比较有效变化,在权限、密码或启停任一变化时只递增一次 `token_version`,并在同一事务写入账号字段、`updated_by``updated_at`。响应仍不返回密码或 hash。
## 11. admin-web 行为
### 11.1 路由与导航
- `adminRoutes` 增加权限元数据;18 个业务路由使用同名 permission id。
- `accounts` 路由只在 `admin.accountRole === "owner"` 时加入侧栏和移动底栏,不属于 member 可分配列表。
- member 导航只渲染 `admin.tabPermissions` 包含的业务路由。页面组件也必须只在当前路由已授权时挂载,避免隐藏导航后仍发起无权限 API。
- owner 渲染全部业务路由和账号管理路由。
### 11.2 hash 回落
登录成功、`GET /me` 恢复会话、权限刷新和 `hashchange` 时都执行同一解析:
1. 当前 hash 对应可访问路由时保持不变。
2. hash 未知、属于无权限业务 Tab,或 member 访问 `#accounts` 时,使用 `replaceState` 回落到按第 4 节顺序找到的第一可访问业务 Tab。
3. member 权限为空时,不回落 Dashboard;渲染独立的零权限空态,只保留账号信息和退出登录,不挂载任何业务页,也不发起业务 API。
4. owner 的默认项仍可保持 Dashboard;账号管理不改变 18 个业务路由的排序。
后端返回 `403` 时,前端重新请求 `/me` 获取实时权限并执行上述回落。即使前端状态陈旧或被篡改,后端权限 middleware 仍必须拒绝越权请求。
### 11.3 账号管理页
- 权限编辑器展示 18 个明确的 checkbox,每项使用现有 Tab 中文名称;不能展示或提交 `accounts`
- 创建和编辑使用独立弹窗或抽屉,不在列表下方追加表单。
- 编辑时密码字段默认空,空表示请求中省略 `password`;页面永不展示现有密码或 hash。
- 停用使用开关并二次确认。保存成功后以 API 返回 account snapshot 更新列表。
- owner 以只读项显示在账号列表首位,页面顶部同时标明当前 owner;owner 行不可进入编辑表单,后端也拒绝以 owner subject 调用更新接口。
## 12. 实现落点
建议按以下边界落地,避免在前端或 `api-server` 重新发明持久化规则:
- `shared-contracts``ADMIN_TAB_PERMISSIONS`、扩展后的 `AdminSessionPayload`、账号管理 request/response DTO。
- `spacetime-module`:私有表、输入类型、typed procedures、唯一性与版本递增事务。
- `spacetime-client`:生成绑定、row mapper、登录查询与账号管理 facade。
- `api-server`owner/member 登录编排、Argon2id、逐请求账号解析、权限 middleware、账号管理 handlers。
- `apps/admin-web`:权限感知路由、hash 回落、零权限空态、owner-only 账号管理页。
不能将 `permissions_json` 的解析与授权只放在前端;不能让 admin-web 直连 SpacetimeDB;不能用进程内 member 列表替代 `admin_account`
## 13. 迁移、绑定与发布顺序
`admin_account` 是新增私有表,没有旧数据回填。原环境变量 owner 不入表,因此迁移不创建 owner 行。
实现 schema 后必须:
1.`admin_account` 加入 `server-rs/crates/spacetime-module/src/migration.rs` 的导入导出/迁移表目录,保证备份迁移保留 member。
2. 将表和 procedures 加入 `docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md` 的机器可读表目录与后台契约。
3. 生成并提交 `spacetime-client` bindings,不手改生成文件。
4. 先发布 SpacetimeDB module,再发布依赖新 procedures 的 api-server,最后发布 admin-web。
本地生成与门禁命令:
```bash
npm run spacetime:generate
npm run check:admin-account-procedures
npm run check:spacetime-runtime-access
npm run check:spacetime-schema
npm run check:server-rs-ddd
```
本地迁移联调优先使用项目脚本:
```bash
npm run dev:spacetime
npm run dev:api-server
```
需要人工发布到指定 SpacetimeDB 时必须显式目标,不使用 `spacetime --root-dir`
```bash
spacetime publish <database> \
--server <server-url> \
--module-path server-rs/crates/spacetime-module \
--yes=migrate
```
回滚旧 api-server 时保留 `admin_account` 表和数据;旧版本只认识 owner,不会读取 member。不得为了回滚删除表或清空 member 数据。
## 14. 测试与验收
### 14.1 后端与数据
- owner 使用原环境变量账号密码登录成功,且不生成 `admin_account` 行。
- owner 用户名匹配但密码错误时,不回退到 member 查询。
- member 密码使用 Argon2id 校验;账号不存在、密码错误、停用账号返回相同登录错误。
- member 私表不能被普通 SpacetimeDB identity 查询或调用 procedure;只有 runtime service identity 可读写。
- 创建重复规范化用户名、owner 保留用户名、未知权限或 `accounts` 权限均失败。
- GET/POST/PUT 账号 API 任何响应和日志都不包含明文密码或 `password_hash`
- 权限、密码、启停更新各自会递增 `token_version`;同一次请求修改多项只递增一次;仅改展示名不递增。
- member 被停用、改密或改权限后,旧 JWT 下一次请求返回 401;重新登录后获得实时权限。
- API-to-Tab 矩阵逐路由覆盖 `modules/admin.rs`,每条路由至少测试 owner 成功、具备权限的 member 成功、缺权限 member 返回 403。
- 三个共享读取接口分别覆盖每个允许 permission 的成功用例,以及无关 permission 的 403 用例。
- owner-only 账号 API 对任意 member 都返回 403,即使其 `permissions_json` 被污染为包含 `accounts`
### 14.2 前端
- owner 看到 18 个业务 Tab 和账号管理;member 只看到被分配的业务 Tab。
- 每个一级 Tab 内的二级 Tab、弹窗和写操作继承一级权限并正常使用,不出现“页面可见但内部 API 403”的错误映射。
- 直接输入无权限 hash 自动替换为第一可访问项,不短暂挂载无权限页面。
- 当前 Tab 权限被 owner 收回后,下一请求触发重新登录;新会话恢复后落到第一可访问项。
- 零权限 member 登录后显示空态,不回落 Dashboard、不发送 Dashboard 或其它业务请求,并可正常退出。
- 桌面侧栏和移动底栏应用同一过滤结果;账号创建/编辑弹层在移动端和桌面端都可操作。
- 编辑账号时页面不读取、不显示、不回填密码;不改密码时 PUT 请求不包含 `password`
### 14.3 建议验证命令
```bash
cargo test -p spacetime-module --manifest-path server-rs/Cargo.toml admin_account
cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml admin_account
cargo test -p api-server --manifest-path server-rs/Cargo.toml admin
npm run check:admin-account-procedures
npx vitest run apps/admin-web/src/app/adminRoutes.test.ts \
apps/admin-web/src/app/AdminApp.test.tsx \
apps/admin-web/src/pages/AdminAccountManagementPage.test.tsx
npm run admin-web:typecheck
npm run check:encoding
git diff --check
```
API smoke 使用 `npm run dev:api-server` 启动后端,先检查 `/healthz`,再用 owner 和至少两个不同权限集合的 member 验证登录、`/admin/api/me`、共享 OR 路由、越权 403 和 `token_version` 即时失效。
## 15. 非目标
- 不提供 member 自助改密、忘记密码、MFA、SSO 或外部身份源。
- 不提供按钮级、字段级、只读/读写分离权限。
- 不提供 owner 数据库化、多个 owner 或 member 删除。
- 不改变普通用户认证、普通用户 `token_version` 或主站权限体系。
- 不把后台账号暴露为公开 SpacetimeDB 表、浏览器 subscription 或普通用户账号。
@@ -54,7 +54,7 @@ npm run check:server-rs-ddd
路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组:
- 健康检查:`GET /healthz`
- 后台管理:`/admin/api/*`,包括登录、Dashboard 运营看板、概览、HTTP debug、埋点、表查询、精选审核、素材查询、创作入口开关、作品互动配置、作品可见性、兑换码、邀请码、任务配置充值商品配置Dashboard 指标口径见 [`docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。
- 后台管理:`/admin/api/*`,包括登录、Dashboard 运营看板、概览、HTTP debug、埋点、表查询、精选审核、素材查询、创作入口开关、作品互动配置、作品可见性、兑换码、邀请码、任务配置充值商品配置和后台账号管理。环境变量管理员固定作为 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)。
- 认证与账号:`/api/auth/*``/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
- 平台基础能力:`/api/llm/*``/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
@@ -503,6 +503,13 @@ npm run check:server-rs-ddd
- 说明:外部 OpenAPI 调用使用的账号级 API Key 凭据表,只保存 key prefix、SHA-256 hash、作用域、撤销状态和使用时间;明文 Key 只在 `/api/profile/api-keys` 创建接口返回一次,不进入 SpacetimeDB,且 API Key 管理接口不写入外部 OpenAPI JSON。v1 默认作用域为 `editor:project``editor:canvas``editor:image-generate``editor:asset`;其中 `editor:project` 覆盖项目列表、最近项目、创建、读取、重命名和删除,`editor:canvas` 覆盖默认画布布局保存,`editor:image-generate` 覆盖编辑器现有图片生成、重绘 / 调整、规范图、宣发素材、图标 spritesheet 生成 / 拆分、UI 设计图素材拆分、角色动画、视频、音效和背景音乐生成,`editor:asset` 覆盖素材直传凭证、素材对象确认、签名读取、账号级素材库和项目画布资源记录操作。
- 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。
### `admin_account`
- Rust 结构体:`AdminAccount`
- 源码:`server-rs/crates/spacetime-module/src/admin_account_storage.rs`
- 说明:后台 member 私有账号表,保存规范化用户名、展示名、Argon2id 密码摘要、一级 Tab 权限 JSON、启停状态、会话版本和创建 / 更新审计字段。原环境变量管理员作为虚拟 owner,不写入该表。账号查询与写入 procedure 只接受 runtime service identityHTTP 列表和写响应不返回密码摘要。
- 索引:`account_id` 为主键,`username` 为唯一登录名;权限、密码或启停状态发生变化时在同一事务递增 `token_version`,使旧 JWT 下一次请求立即失效。
### `editor_agent_conversation`
- Rust 结构体:`EditorAgentConversation`
@@ -202,6 +202,12 @@ SpacetimeDB bindings
npm run spacetime:generate
```
后台账号 procedure 的 identity、唯一索引和版本事务使用隔离 smoke 验证;脚本会在随机本机端口启动临时 SpacetimeDB 2.6、发布当前 module,结束后自动关闭并清理临时数据:
```bash
npm run check:admin-account-procedures
```
## CodeGraph 本地代码索引
项目已安装 `@colbymchenry/codegraph` 作为开发期依赖,用于在本地生成语义代码索引,辅助 AI / IDE 做符号搜索、调用关系和影响范围分析。索引目录为 `.codegraph/`,其中 `config.json` 可提交,数据库、缓存和日志由 `.codegraph/.gitignore` 保持本机私有。
+1
View File
@@ -21,6 +21,7 @@
"spacetime:wechat-virtual-payment:reconcile": "node scripts/reconcile-wechat-virtual-payment-order.mjs",
"check:wechat-virtual-payment-reconcile": "node scripts/check-wechat-virtual-payment-reconcile.mjs",
"check:api-server-env": "node scripts/check-api-server-env.mjs",
"check:admin-account-procedures": "node scripts/check-admin-account-procedures.mjs",
"check:spacetime-runtime-access": "node scripts/check-spacetime-runtime-access.mjs",
"deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
"build:production-release": "node scripts/run-bash-script.mjs scripts/build-production-release.sh",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More