后台支持多账号与页面访问权限
保留环境变量管理员作为 owner,并新增可持久化管理的 member 账号 建立一级页面权限、实时会话失效和后端逐请求鉴权 统一操作记录与配置记录展示管理员显示名称,隐藏内部主体标识 补充账号管理页面、生成绑定、定向测试和技术文档
This commit is contained in:
@@ -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: [] }), {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)。
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-14 后台账号采用 owner 引导账号与一级 Tab 实时授权
|
||||
|
||||
- 背景:后台此前只支持一组环境变量管理员,所有 `/admin/api/*` 共用统一 admin 门禁,无法给运营、审核等人员分配独立账号和页面范围。
|
||||
- 决策:现有 `GENARRATIVE_ADMIN_USERNAME/PASSWORD` 账号固定作为不可编辑 owner;新增 member 独立保存到私有 `admin_account` 表,密码使用 Argon2id 摘要。member 权限粒度固定为后台 18 个一级 Tab,“账号管理”只允许 owner 且不可授予 member。member 每次请求重新读取当前账号并校验启停、`token_version` 和 Tab 权限;权限、密码或启停变化递增版本并立即淘汰旧 JWT。前端导航过滤和页面挂载门禁只负责体验,正式授权由 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 定向测试、完整路由矩阵测试、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,428 @@
|
||||
# 后台管理多账号与 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 可以创建、修改、启停 member;member 保存在 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 哈希与校验能力。
|
||||
- 不提供物理删除 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 校验,不能存明文或可逆密文。
|
||||
|
||||
### 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` 时拒绝。
|
||||
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 id;member 返回数据库中的实时规范化数组。`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: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
|
||||
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 identity;HTTP 列表和写响应不返回密码摘要。
|
||||
- 索引:`account_id` 为主键,`username` 为唯一登录名;权限、密码或启停状态发生变化时在同一事务递增 `token_version`,使旧 JWT 下一次请求立即失效。
|
||||
|
||||
### `editor_agent_conversation`
|
||||
|
||||
- Rust 结构体:`EditorAgentConversation`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,282 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use platform_auth::hash_password;
|
||||
use serde_json::Value;
|
||||
use shared_contracts::admin::{
|
||||
ADMIN_TAB_PERMISSIONS, AdminAccountListResponse, AdminAccountPayload, AdminAccountRole,
|
||||
AdminCreateAccountRequest, AdminCreateAccountResponse, AdminUpdateAccountRequest,
|
||||
AdminUpdateAccountResponse,
|
||||
};
|
||||
use spacetime_client::{
|
||||
AdminAccountCreateRecordInput, AdminAccountRecord, AdminAccountUpdateRecordInput,
|
||||
SpacetimeClientError,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
admin::AuthenticatedAdmin, api_response::json_success_body, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
const ADMIN_USERNAME_MIN_CHARS: usize = 3;
|
||||
const ADMIN_USERNAME_MAX_CHARS: usize = 64;
|
||||
const ADMIN_DISPLAY_NAME_MAX_CHARS: usize = 80;
|
||||
const ADMIN_PASSWORD_MIN_CHARS: usize = 6;
|
||||
const ADMIN_PASSWORD_MAX_CHARS: usize = 128;
|
||||
|
||||
pub async fn admin_list_accounts(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let runtime = state.admin_runtime().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用")
|
||||
})?;
|
||||
let mut accounts = vec![AdminAccountPayload {
|
||||
account_id: runtime.subject().to_string(),
|
||||
username: runtime.username().to_string(),
|
||||
display_name: runtime.display_name().to_string(),
|
||||
account_role: AdminAccountRole::Owner,
|
||||
tab_permissions: ADMIN_TAB_PERMISSIONS
|
||||
.iter()
|
||||
.map(|permission| (*permission).to_string())
|
||||
.collect(),
|
||||
enabled: true,
|
||||
token_version: 1,
|
||||
created_by: "environment".to_string(),
|
||||
updated_by: "environment".to_string(),
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
}];
|
||||
accounts.extend(
|
||||
state
|
||||
.spacetime_client()
|
||||
.list_admin_accounts()
|
||||
.await
|
||||
.map_err(map_admin_account_error)?
|
||||
.into_iter()
|
||||
.map(admin_account_payload),
|
||||
);
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminAccountListResponse { accounts },
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn admin_create_account(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminCreateAccountRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
require_owner(&admin)?;
|
||||
let username = normalize_username(&payload.username)?;
|
||||
let owner_username = state
|
||||
.admin_runtime()
|
||||
.map(|runtime| runtime.username().trim().to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
if username == owner_username {
|
||||
return Err(AppError::from_status(StatusCode::CONFLICT).with_message("后台用户名已存在"));
|
||||
}
|
||||
let display_name = normalize_display_name(&payload.display_name)?;
|
||||
validate_password(&payload.password)?;
|
||||
let tab_permissions = normalize_tab_permissions(payload.tab_permissions)?;
|
||||
let password_hash = hash_password(&payload.password).await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
})?;
|
||||
|
||||
let account = state
|
||||
.spacetime_client()
|
||||
.create_admin_account(AdminAccountCreateRecordInput {
|
||||
account_id: format!("admin-account-{}", Uuid::new_v4()),
|
||||
username,
|
||||
display_name,
|
||||
password_hash,
|
||||
tab_permissions,
|
||||
enabled: payload.enabled,
|
||||
created_by: admin.session().subject.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_admin_account_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminCreateAccountResponse {
|
||||
account: admin_account_payload(account),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn admin_update_account(
|
||||
State(state): State<AppState>,
|
||||
Path(account_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminUpdateAccountRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
require_owner(&admin)?;
|
||||
let account_id = account_id.trim().to_string();
|
||||
if account_id.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("后台账号 ID 不能为空")
|
||||
);
|
||||
}
|
||||
if state
|
||||
.admin_runtime()
|
||||
.is_some_and(|runtime| runtime.subject() == account_id.as_str())
|
||||
{
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::FORBIDDEN).with_message("owner 账号不能通过后台修改")
|
||||
);
|
||||
}
|
||||
let display_name = normalize_display_name(&payload.display_name)?;
|
||||
let tab_permissions = normalize_tab_permissions(payload.tab_permissions)?;
|
||||
let password_hash = match payload.password {
|
||||
Some(password) => {
|
||||
validate_password(&password)?;
|
||||
Some(hash_password(&password).await.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.with_message(error.to_string())
|
||||
})?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let account = state
|
||||
.spacetime_client()
|
||||
.update_admin_account(AdminAccountUpdateRecordInput {
|
||||
account_id,
|
||||
display_name,
|
||||
password_hash,
|
||||
tab_permissions,
|
||||
enabled: payload.enabled,
|
||||
updated_by: admin.session().subject.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_admin_account_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminUpdateAccountResponse {
|
||||
account: admin_account_payload(account),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn require_owner(admin: &AuthenticatedAdmin) -> Result<(), AppError> {
|
||||
if admin.session().account_role == AdminAccountRole::Owner {
|
||||
return Ok(());
|
||||
}
|
||||
Err(AppError::from_status(StatusCode::FORBIDDEN).with_message("仅 owner 可管理后台账号"))
|
||||
}
|
||||
|
||||
fn normalize_username(raw: &str) -> Result<String, AppError> {
|
||||
let username = raw.trim().to_ascii_lowercase();
|
||||
let char_count = username.chars().count();
|
||||
if !(ADMIN_USERNAME_MIN_CHARS..=ADMIN_USERNAME_MAX_CHARS).contains(&char_count)
|
||||
|| !username
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("后台用户名格式无效")
|
||||
);
|
||||
}
|
||||
Ok(username)
|
||||
}
|
||||
|
||||
fn normalize_display_name(raw: &str) -> Result<String, AppError> {
|
||||
let display_name = raw.trim().to_string();
|
||||
if display_name.is_empty() || display_name.chars().count() > ADMIN_DISPLAY_NAME_MAX_CHARS {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("后台显示名称格式无效")
|
||||
);
|
||||
}
|
||||
Ok(display_name)
|
||||
}
|
||||
|
||||
fn validate_password(password: &str) -> Result<(), AppError> {
|
||||
let char_count = password.chars().count();
|
||||
if !(ADMIN_PASSWORD_MIN_CHARS..=ADMIN_PASSWORD_MAX_CHARS).contains(&char_count) {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("后台密码长度必须为 6 至 128 个字符"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_tab_permissions(permissions: Vec<String>) -> Result<Vec<String>, AppError> {
|
||||
let requested = permissions
|
||||
.into_iter()
|
||||
.map(|permission| permission.trim().to_string())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
if requested
|
||||
.iter()
|
||||
.any(|permission| !ADMIN_TAB_PERMISSIONS.contains(&permission.as_str()))
|
||||
{
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("后台 Tab 权限包含未知值")
|
||||
);
|
||||
}
|
||||
Ok(ADMIN_TAB_PERMISSIONS
|
||||
.iter()
|
||||
.filter(|permission| requested.contains(**permission))
|
||||
.map(|permission| (*permission).to_string())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn admin_account_payload(account: AdminAccountRecord) -> AdminAccountPayload {
|
||||
AdminAccountPayload {
|
||||
account_id: account.account_id,
|
||||
username: account.username,
|
||||
display_name: account.display_name,
|
||||
account_role: AdminAccountRole::Member,
|
||||
tab_permissions: account.tab_permissions,
|
||||
enabled: account.enabled,
|
||||
token_version: account.token_version,
|
||||
created_by: account.created_by,
|
||||
updated_by: account.updated_by,
|
||||
created_at: account.created_at,
|
||||
updated_at: account.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_admin_account_error(error: SpacetimeClientError) -> AppError {
|
||||
let message = error.to_string();
|
||||
let status = if message.contains("已存在") {
|
||||
StatusCode::CONFLICT
|
||||
} else if message.contains("不存在") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else if message.contains("不能为空") || message.contains("无效") {
|
||||
StatusCode::BAD_REQUEST
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
AppError::from_status(status).with_message(message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tab_permissions_are_validated_deduplicated_and_ordered() {
|
||||
assert_eq!(
|
||||
normalize_tab_permissions(vec![
|
||||
"tracking".to_string(),
|
||||
"dashboard".to_string(),
|
||||
"tracking".to_string(),
|
||||
])
|
||||
.expect("permissions should normalize"),
|
||||
["dashboard", "tracking"]
|
||||
);
|
||||
assert!(normalize_tab_permissions(vec!["accounts".to_string()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usernames_are_ascii_and_lowercase() {
|
||||
assert_eq!(normalize_username(" Operator_1 ").unwrap(), "operator_1");
|
||||
assert!(normalize_username("管理员").is_err());
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ use shared_contracts::admin::{
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
admin::AuthenticatedAdmin,
|
||||
admin::{AdminDisplayNameDirectory, AuthenticatedAdmin, load_admin_display_name_directory},
|
||||
api_response::json_success_body,
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
@@ -144,6 +144,9 @@ pub async fn admin_get_user_detail(
|
||||
.await
|
||||
.map_err(|error| spacetime_error_response(&request_context, error))?;
|
||||
let summary = map_user_summary(&user);
|
||||
let admin_display_names = load_admin_display_name_directory(&state)
|
||||
.await
|
||||
.map_err(|error| spacetime_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -157,7 +160,7 @@ pub async fn admin_get_user_detail(
|
||||
binding_status: user.binding_status.as_str().to_string(),
|
||||
phone_bound: user.phone_number_masked.is_some(),
|
||||
wechat_bound: user.wechat_bound,
|
||||
wallet: map_wallet(wallet),
|
||||
wallet: map_wallet(wallet, Some(&admin_display_names)),
|
||||
recharge_orders: orders
|
||||
.into_iter()
|
||||
.map(|entry| map_order_entry(entry, Some(summary.clone())))
|
||||
@@ -531,11 +534,14 @@ pub async fn admin_update_wallet_restriction(
|
||||
.admin_upsert_profile_wallet_manual_restriction(input)
|
||||
.await
|
||||
.map_err(|error| spacetime_error_response(&request_context, error))?;
|
||||
let admin_display_names = load_admin_display_name_directory(&state)
|
||||
.await
|
||||
.map_err(|error| spacetime_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminWalletRestrictionResponse {
|
||||
wallet: map_wallet(wallet),
|
||||
wallet: map_wallet(wallet, Some(&admin_display_names)),
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -877,7 +883,7 @@ fn map_order_entry(
|
||||
.map(|value| value.unrecovered_points)
|
||||
.unwrap_or(0),
|
||||
recovery_status: settlement.map(|value| value.recovery_status.as_str().to_string()),
|
||||
wallet: map_wallet(entry.wallet),
|
||||
wallet: map_wallet(entry.wallet, None),
|
||||
refunds: entry.refunds.into_iter().map(map_refund).collect(),
|
||||
active_hold: entry.active_hold.map(map_hold),
|
||||
remaining_refundable_cents,
|
||||
@@ -886,7 +892,10 @@ fn map_order_entry(
|
||||
}
|
||||
}
|
||||
|
||||
fn map_wallet(wallet: RuntimeProfileAdminWalletSnapshot) -> AdminProfileWalletPayload {
|
||||
fn map_wallet(
|
||||
wallet: RuntimeProfileAdminWalletSnapshot,
|
||||
admin_display_names: Option<&AdminDisplayNameDirectory>,
|
||||
) -> AdminProfileWalletPayload {
|
||||
AdminProfileWalletPayload {
|
||||
user_id: wallet.user_id,
|
||||
total_balance: wallet.total_balance,
|
||||
@@ -900,12 +909,20 @@ fn map_wallet(wallet: RuntimeProfileAdminWalletSnapshot) -> AdminProfileWalletPa
|
||||
refund_debt_frozen: wallet.refund_debt_frozen,
|
||||
wallet_frozen: wallet.wallet_frozen,
|
||||
manual_restriction: wallet.manual_restriction.map(|value| {
|
||||
let created_by_admin_display_name = admin_display_names
|
||||
.map(|directory| directory.resolve(&value.created_by_admin_user_id))
|
||||
.unwrap_or_else(|| "已停用管理员".to_string());
|
||||
let updated_by_admin_display_name = admin_display_names
|
||||
.map(|directory| directory.resolve(&value.updated_by_admin_user_id))
|
||||
.unwrap_or_else(|| "已停用管理员".to_string());
|
||||
AdminWalletManualRestrictionPayload {
|
||||
frozen: value.frozen,
|
||||
reason: value.reason,
|
||||
created_by_admin_user_id: value.created_by_admin_user_id,
|
||||
created_by_admin_display_name,
|
||||
created_at_micros: value.created_at_micros,
|
||||
updated_by_admin_user_id: value.updated_by_admin_user_id,
|
||||
updated_by_admin_display_name,
|
||||
updated_at_micros: value.updated_at_micros,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
mod admin;
|
||||
mod admin_accounts;
|
||||
mod admin_recharge;
|
||||
mod ai_generation_drafts;
|
||||
mod aliyun_matting;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user