feat: add wallet settlement audit flow

This commit is contained in:
2026-05-11 22:59:26 +08:00
parent da1e19d0a9
commit c992f1de60
22 changed files with 1452 additions and 44 deletions
+40 -1
View File
@@ -6,6 +6,7 @@ import type {
GatewayAccessRule,
GatewayAccessRuleUpsertRequest,
GatewayApiKey,
GatewayAuditLog,
GatewayTenantUpsertRequest,
GatewayTask,
GatewayUserUpsertRequest,
@@ -20,6 +21,7 @@ import type {
RuntimePolicySet,
UserGroupUpsertRequest,
UserGroup,
WalletBalanceAdjustmentRequest,
} from '@easyai-ai-gateway/contracts';
import {
batchAccessRules,
@@ -38,6 +40,7 @@ import {
deleteUserGroup,
getHealth,
getTask,
listAuditLogs,
listAccessRules,
listApiKeyAccessRules,
listApiKeys,
@@ -61,6 +64,7 @@ import {
loginLocalAccount,
registerLocalAccount,
replacePlatformModels,
setUserWalletBalance,
type HealthResponse,
updateAccessRule,
updateGatewayUser,
@@ -130,6 +134,7 @@ type DataKey =
| 'userGroups'
| 'tasks'
| 'accessRules'
| 'auditLogs'
| 'apiKeys';
export function App() {
@@ -160,6 +165,7 @@ export function App() {
const [pricingRuleSets, setPricingRuleSets] = useState<PricingRuleSet[]>([]);
const [runtimePolicySets, setRuntimePolicySets] = useState<RuntimePolicySet[]>([]);
const [accessRules, setAccessRules] = useState<GatewayAccessRule[]>([]);
const [auditLogs, setAuditLogs] = useState<GatewayAuditLog[]>([]);
const [rateLimitWindows, setRateLimitWindows] = useState<RateLimitWindow[]>([]);
const [tenants, setTenants] = useState<GatewayTenant[]>([]);
const [users, setUsers] = useState<GatewayUser[]>([]);
@@ -238,6 +244,7 @@ export function App() {
const data = useMemo<ConsoleData>(() => ({
accessRules,
auditLogs,
apiKeys,
baseModels,
modelCatalog,
@@ -253,7 +260,7 @@ export function App() {
tenants,
userGroups,
users,
}), [accessRules, apiKeys, baseModels, modelCatalog, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tasks, tenants, userGroups, users]);
}), [accessRules, apiKeys, auditLogs, baseModels, modelCatalog, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tasks, tenants, userGroups, users]);
async function refresh(nextToken = token) {
await ensureRouteData(nextToken, true);
@@ -371,6 +378,9 @@ export function App() {
? listApiKeyAccessRules(nextToken)
: listAccessRules(nextToken))).items);
return;
case 'auditLogs':
setAuditLogs((await listAuditLogs(nextToken)).items);
return;
case 'apiKeys':
setApiKeys((await listApiKeys(nextToken)).items);
}
@@ -527,6 +537,31 @@ export function App() {
}
}
async function saveUserWalletBalance(userId: string, input: WalletBalanceAdjustmentRequest) {
setCoreState('loading');
setCoreMessage('');
try {
const response = await setUserWalletBalance(token, userId, input);
setUsers((current) => current.map((user) => user.id === userId
? {
...user,
walletAccounts: [
response.account,
...(user.walletAccounts ?? []).filter((account) => account.id !== response.account.id),
],
}
: user));
setAuditLogs((current) => [response.auditLog, ...current.filter((item) => item.id !== response.auditLog.id)]);
invalidateDataKeys('auditLogs');
setCoreState('ready');
setCoreMessage('用户余额已更新,审计日志已记录。');
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '更新用户余额失败');
throw err;
}
}
async function removeUser(userId: string) {
setCoreState('loading');
setCoreMessage('');
@@ -697,6 +732,7 @@ export function App() {
setPricingRuleSets([]);
setRuntimePolicySets([]);
setAccessRules([]);
setAuditLogs([]);
setRateLimitWindows([]);
setTenants([]);
setUsers([]);
@@ -866,6 +902,7 @@ export function App() {
onSaveAccessRule={saveAccessRule}
onSaveTenant={saveTenant}
onSaveUser={saveUser}
onSetUserWalletBalance={saveUserWalletBalance}
onSaveUserGroup={saveUserGroup}
onSectionChange={navigateAdminSection}
/>
@@ -1015,6 +1052,8 @@ function dataKeysForRoute(
return ['users', 'tenants', 'userGroups'];
case 'userGroups':
return ['userGroups'];
case 'auditLogs':
return ['auditLogs'];
case 'accessRules':
return ['accessRules', 'userGroups', 'platforms', 'models'];
default:
+19
View File
@@ -9,6 +9,7 @@ import type {
GatewayAccessRule,
GatewayAccessRuleUpsertRequest,
GatewayApiKey,
GatewayAuditLog,
GatewayTenant,
GatewayTenantUpsertRequest,
GatewayTask,
@@ -27,6 +28,8 @@ import type {
RuntimePolicySetUpsertRequest,
UserGroup,
UserGroupUpsertRequest,
WalletAdjustmentResponse,
WalletBalanceAdjustmentRequest,
} from '@easyai-ai-gateway/contracts';
import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types';
@@ -287,6 +290,18 @@ export async function updateGatewayUser(token: string, userId: string, input: Ga
});
}
export async function setUserWalletBalance(
token: string,
userId: string,
input: WalletBalanceAdjustmentRequest,
): Promise<WalletAdjustmentResponse> {
return request<WalletAdjustmentResponse>(`/api/admin/users/${userId}/wallet`, {
body: input,
method: 'PATCH',
token,
});
}
export async function deleteGatewayUser(token: string, userId: string): Promise<void> {
await request<void>(`/api/admin/users/${userId}`, {
method: 'DELETE',
@@ -294,6 +309,10 @@ export async function deleteGatewayUser(token: string, userId: string): Promise<
});
}
export async function listAuditLogs(token: string): Promise<ListResponse<GatewayAuditLog>> {
return request<ListResponse<GatewayAuditLog>>('/api/admin/audit-logs', { token });
}
export async function listUserGroups(token: string): Promise<ListResponse<UserGroup>> {
return request<ListResponse<UserGroup>>('/api/admin/user-groups', { token });
}
+2
View File
@@ -3,6 +3,7 @@ import type {
CatalogProvider,
GatewayAccessRule,
GatewayApiKey,
GatewayAuditLog,
GatewayTask,
GatewayTenant,
GatewayUser,
@@ -18,6 +19,7 @@ import type {
export interface ConsoleData {
accessRules: GatewayAccessRule[];
auditLogs: GatewayAuditLog[];
apiKeys: GatewayApiKey[];
baseModels: BaseModelCatalogItem[];
modelCatalog: ModelCatalogResponse;
+8 -1
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { Boxes, Building2, Gauge, KeyRound, Route, ServerCog, ShieldCheck, UsersRound, Workflow } from 'lucide-react';
import { Boxes, Building2, Gauge, History, KeyRound, Route, ServerCog, ShieldCheck, UsersRound, Workflow } from 'lucide-react';
import type {
BaseModelUpsertRequest,
CatalogProviderUpsertRequest,
@@ -10,6 +10,7 @@ import type {
PricingRuleSetUpsertRequest,
RuntimePolicySetUpsertRequest,
UserGroupUpsertRequest,
WalletBalanceAdjustmentRequest,
} from '@easyai-ai-gateway/contracts';
import type { ConsoleData, StatItem } from '../app-state';
import { EntityTable } from '../components/EntityTable';
@@ -17,6 +18,7 @@ import { StatGrid } from '../components/StatGrid';
import { Badge, Card, CardContent, CardHeader, CardTitle, Tabs } from '../components/ui';
import type { AdminSection, LoadState, PlatformWithModelsInput } from '../types';
import { AccessRulesPanel } from './admin/AccessRulesPanel';
import { AuditLogsPanel } from './admin/AuditLogsPanel';
import { BaseModelCatalogPanel } from './admin/BaseModelCatalogPanel';
import { TenantsPanel, UserGroupsPanel, UsersPanel } from './admin/IdentityManagementPanels';
import { PlatformManagementPanel } from './admin/PlatformManagementPanel';
@@ -35,6 +37,7 @@ const tabs = [
{ value: 'users', label: '用户', icon: <UsersRound size={15} /> },
{ value: 'userGroups', label: '用户组', icon: <UsersRound size={15} /> },
{ value: 'accessRules', label: '模型权限', icon: <KeyRound size={15} /> },
{ value: 'auditLogs', label: '审计日志', icon: <History size={15} /> },
] satisfies Array<{ value: AdminSection; label: string; icon: ReactNode }>;
export function AdminPage(props: {
@@ -63,6 +66,7 @@ export function AdminPage(props: {
onSaveAccessRule: (input: GatewayAccessRuleUpsertRequest, ruleId?: string) => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
onSectionChange: (value: AdminSection) => void;
}) {
@@ -141,6 +145,7 @@ export function AdminPage(props: {
{props.section === 'tenants' && <TenantsPanel {...identityPanelProps(props)} />}
{props.section === 'users' && <UsersPanel {...identityPanelProps(props)} />}
{props.section === 'userGroups' && <UserGroupsPanel {...identityPanelProps(props)} />}
{props.section === 'auditLogs' && <AuditLogsPanel auditLogs={props.data.auditLogs} message={props.operationMessage} />}
</div>
</div>
</div>
@@ -156,6 +161,7 @@ function identityPanelProps(props: {
onDeleteUserGroup: (groupId: string) => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
}) {
return {
@@ -167,6 +173,7 @@ function identityPanelProps(props: {
onDeleteUserGroup: props.onDeleteUserGroup,
onSaveTenant: props.onSaveTenant,
onSaveUser: props.onSaveUser,
onSetUserWalletBalance: props.onSetUserWalletBalance,
onSaveUserGroup: props.onSaveUserGroup,
};
}
@@ -0,0 +1,93 @@
import { History, ShieldCheck } from 'lucide-react';
import type { GatewayAuditLog } from '@easyai-ai-gateway/contracts';
import { Badge, Card, CardContent, CardHeader, CardTitle, Table, TableCell, TableHead, TableRow } from '../../components/ui';
export function AuditLogsPanel(props: { auditLogs: GatewayAuditLog[]; message?: string }) {
return (
<div className="pageStack">
<Card>
<CardHeader>
<div className="identityHeaderTitle">
<div className="iconBox"><ShieldCheck size={17} /></div>
<div>
<CardTitle></CardTitle>
<p className="mutedText">便</p>
</div>
<Badge variant="secondary">{props.auditLogs.length}</Badge>
</div>
</CardHeader>
{props.message && <CardContent><p className="formMessage">{props.message}</p></CardContent>}
</Card>
{props.auditLogs.length ? (
<Table className="identityDataTable auditLogTable">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
{props.auditLogs.map((item) => (
<TableRow key={item.id}>
<TableCell>
<span className="identityTableName">
<strong>{actionLabel(item.action)}</strong>
<small>{item.category}</small>
</span>
</TableCell>
<TableCell>{item.actorUsername || item.actorUserId || '系统'}</TableCell>
<TableCell>
<span className="identityTableName">
<strong>{targetLabel(item.targetType)}</strong>
<small>{shortId(item.targetId)}</small>
</span>
</TableCell>
<TableCell>{auditSummary(item)}</TableCell>
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
</TableRow>
))}
</Table>
) : (
<Card>
<CardContent className="emptyState">
<History size={18} />
<strong></strong>
<span></span>
</CardContent>
</Card>
)}
</div>
);
}
function actionLabel(action: string) {
if (action === 'wallet.balance.set') return '余额调整';
return action;
}
function targetLabel(targetType: string) {
if (targetType === 'gateway_user') return '用户';
return targetType;
}
function auditSummary(item: GatewayAuditLog) {
const metadata = item.metadata ?? {};
const direction = typeof metadata.direction === 'string' ? metadata.direction : '';
const amount = typeof metadata.amount === 'number' ? metadata.amount : undefined;
const currency = typeof metadata.currency === 'string' ? metadata.currency : 'resource';
const reason = typeof metadata.reason === 'string' ? metadata.reason : '';
const prefix = amount === undefined ? '' : `${direction === 'debit' ? '-' : '+'}${formatBalance(amount)} ${currency}`;
return [prefix, reason].filter(Boolean).join(' · ') || '已记录';
}
function formatDateTime(value: string) {
return value ? new Date(value).toLocaleString() : '-';
}
function formatBalance(value: number) {
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 6 }).format(value);
}
function shortId(value: string) {
return value.length > 12 ? `${value.slice(0, 8)}...${value.slice(-4)}` : value;
}
@@ -1,12 +1,14 @@
import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { Building2, KeyRound, Pencil, Plus, RotateCcw, Trash2, UserRound, UsersRound } from 'lucide-react';
import { Building2, CircleDollarSign, KeyRound, Pencil, Plus, RotateCcw, Trash2, UserRound, UsersRound } from 'lucide-react';
import type {
GatewayTenant,
GatewayTenantUpsertRequest,
GatewayUser,
GatewayUserUpsertRequest,
GatewayWalletAccount,
UserGroup,
UserGroupUpsertRequest,
WalletBalanceAdjustmentRequest,
} from '@easyai-ai-gateway/contracts';
import {
Badge,
@@ -64,6 +66,12 @@ type UserForm = {
status: string;
};
type WalletForm = {
currency: string;
balance: string;
reason: string;
};
type UserGroupForm = {
groupKey: string;
name: string;
@@ -215,6 +223,8 @@ export function UsersPanel(props: IdentityPanelProps) {
const [form, setForm] = useState<UserForm>(() => defaultUserForm(props.data.tenants[0]));
const [localError, setLocalError] = useState('');
const [pendingDeleteUser, setPendingDeleteUser] = useState<GatewayUser | null>(null);
const [walletUser, setWalletUser] = useState<GatewayUser | null>(null);
const [walletForm, setWalletForm] = useState<WalletForm>(() => defaultWalletForm());
const tenantById = useMemo(() => new Map(props.data.tenants.map((tenant) => [tenant.id, tenant])), [props.data.tenants]);
@@ -238,6 +248,22 @@ export function UsersPanel(props: IdentityPanelProps) {
setLocalError('');
}
function openWalletDialog(user: GatewayUser) {
const wallet = primaryWallet(user);
setLocalError('');
setWalletUser(user);
setWalletForm({
currency: wallet?.currency ?? 'resource',
balance: String(wallet?.balance ?? 0),
reason: '',
});
}
function closeWalletDialog() {
setWalletUser(null);
setWalletForm(defaultWalletForm());
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
@@ -258,6 +284,32 @@ export function UsersPanel(props: IdentityPanelProps) {
}
}
async function submitWallet(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
if (!walletUser) return;
const balance = Number(walletForm.balance);
if (!Number.isFinite(balance) || balance < 0) {
setLocalError('余额必须是非负数字');
return;
}
if (!walletForm.reason.trim()) {
setLocalError('请填写调整原因');
return;
}
try {
await props.onSetUserWalletBalance(walletUser.id, {
currency: walletForm.currency,
balance,
reason: walletForm.reason.trim(),
idempotencyKey: newIdempotencyKey(),
});
closeWalletDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '更新余额失败');
}
}
function selectTenant(gatewayTenantId: string) {
const tenant = tenantById.get(gatewayTenantId);
setForm({ ...form, gatewayTenantId, tenantKey: tenant?.tenantKey ?? form.tenantKey });
@@ -281,6 +333,7 @@ export function UsersPanel(props: IdentityPanelProps) {
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
@@ -291,9 +344,10 @@ export function UsersPanel(props: IdentityPanelProps) {
<TableCell>{roleLabel(user.roles)}</TableCell>
<TableCell>{tenantName(props.data.tenants, user.gatewayTenantId, user.tenantKey)}</TableCell>
<TableCell>{groupName(props.data.userGroups, user.defaultUserGroupId)}</TableCell>
<TableCell>{walletSummary(user)}</TableCell>
<TableCell>{user.source}</TableCell>
<TableCell><Badge variant={user.status === 'active' ? 'success' : 'secondary'}>{user.status}</Badge></TableCell>
<TableCell><TableActions onEdit={() => editUser(user)} onDelete={() => setPendingDeleteUser(user)} /></TableCell>
<TableCell><TableActions onEdit={() => editUser(user)} onDelete={() => setPendingDeleteUser(user)} onWallet={() => openWalletDialog(user)} /></TableCell>
</TableRow>
))}
</Table>
@@ -343,6 +397,20 @@ export function UsersPanel(props: IdentityPanelProps) {
onCancel={() => setPendingDeleteUser(null)}
onConfirm={() => pendingDeleteUser ? deleteUser(pendingDeleteUser) : undefined}
/>
<FormDialog
ariaLabel="修改用户余额"
className="identityDialog walletDialog"
eyebrow="Wallet Adjustment"
footer={<WalletDialogFooter loading={props.state === 'loading'} onCancel={closeWalletDialog} />}
open={Boolean(walletUser)}
title={`修改余额${walletUser ? ` · ${walletUser.displayName || walletUser.username}` : ''}`}
onClose={closeWalletDialog}
onSubmit={submitWallet}
>
<Label><Input size="sm" value={walletForm.currency} onChange={(event) => setWalletForm({ ...walletForm, currency: event.target.value })} placeholder="resource" /></Label>
<Label><Input size="sm" value={walletForm.balance} inputMode="decimal" onChange={(event) => setWalletForm({ ...walletForm, balance: event.target.value })} /></Label>
<Label className="spanTwo"><Textarea size="sm" rows={3} value={walletForm.reason} onChange={(event) => setWalletForm({ ...walletForm, reason: event.target.value })} placeholder="例如:线下充值确认 / 客服补偿 / 账务修正" /></Label>
</FormDialog>
</div>
);
}
@@ -476,6 +544,7 @@ type IdentityPanelProps = {
onDeleteUserGroup: (groupId: string) => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onSetUserWalletBalance: (userId: string, input: WalletBalanceAdjustmentRequest) => Promise<void>;
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
};
@@ -515,9 +584,10 @@ function IdentityName(props: { title: string; subtitle: string }) {
);
}
function TableActions(props: { onDelete: () => void; onEdit: () => void }) {
function TableActions(props: { onDelete: () => void; onEdit: () => void; onWallet?: () => void }) {
return (
<span className="tableActions">
{props.onWallet && <Button type="button" variant="outline" size="icon" title="余额" onClick={props.onWallet}><CircleDollarSign size={14} /></Button>}
<Button type="button" variant="outline" size="icon" title="修改" onClick={props.onEdit}><Pencil size={14} /></Button>
<Button type="button" variant="destructive" size="icon" title="删除" onClick={props.onDelete}><Trash2 size={14} /></Button>
</span>
@@ -545,6 +615,15 @@ function DialogFooter(props: { editing: boolean; loading: boolean; onCancel: ()
);
}
function WalletDialogFooter(props: { loading: boolean; onCancel: () => void }) {
return (
<>
<Button type="button" variant="outline" onClick={props.onCancel}><RotateCcw size={15} /></Button>
<Button type="submit" disabled={props.loading}><CircleDollarSign size={15} /></Button>
</>
);
}
function JsonField(props: { label: string; value: string; onChange: (value: string) => void }) {
return (
<Label className="spanTwo">
@@ -631,6 +710,14 @@ function defaultUserForm(tenant?: GatewayTenant): UserForm {
};
}
function defaultWalletForm(): WalletForm {
return {
currency: 'resource',
balance: '0',
reason: '',
};
}
function userToForm(user: GatewayUser): UserForm {
return {
userKey: user.userKey,
@@ -745,6 +832,27 @@ function roleLabel(roles?: string[]) {
return roleOptions.find((role) => role.value === roleValue(roles))?.label ?? '普通用户';
}
function primaryWallet(user: GatewayUser): GatewayWalletAccount | undefined {
return (user.walletAccounts ?? []).find((account) => account.currency === 'resource') ?? user.walletAccounts?.[0];
}
function walletSummary(user: GatewayUser) {
const wallet = primaryWallet(user);
if (!wallet) return '0 resource';
return `${formatBalance(wallet.balance)} ${wallet.currency}`;
}
function formatBalance(value: number) {
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 6 }).format(value);
}
function newIdempotencyKey() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return `wallet-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function discountSummary(group: UserGroup) {
const billing = group.billingDiscountPolicy?.discountFactor ?? group.billingDiscountPolicy?.factor;
const recharge = group.rechargeDiscountPolicy?.discountFactor ?? group.rechargeDiscountPolicy?.factor;
+1
View File
@@ -34,6 +34,7 @@ const adminPaths: Record<AdminSection, string> = {
tenants: '/admin/tenants',
users: '/admin/users',
userGroups: '/admin/user-groups',
auditLogs: '/admin/audit-logs',
runtime: '/admin/runtime',
accessRules: '/admin/access-rules',
};
+10 -1
View File
@@ -621,12 +621,21 @@
min-width: 880px;
}
.userTable .shTableRow,
.userTable .shTableRow {
grid-template-columns: minmax(210px, 1.25fr) minmax(100px, 0.55fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) minmax(120px, 0.65fr) minmax(100px, 0.55fr) minmax(88px, 0.5fr) minmax(112px, 0.6fr);
min-width: 1120px;
}
.groupTable .shTableRow {
grid-template-columns: minmax(210px, 1.25fr) minmax(100px, 0.55fr) minmax(150px, 0.8fr) minmax(150px, 0.8fr) minmax(100px, 0.55fr) minmax(88px, 0.5fr) minmax(86px, 0.5fr);
min-width: 980px;
}
.auditLogTable .shTableRow {
grid-template-columns: minmax(150px, 0.9fr) minmax(140px, 0.8fr) minmax(140px, 0.8fr) minmax(260px, 1.4fr) minmax(160px, 0.9fr);
min-width: 920px;
}
.identityTableName {
display: grid;
min-width: 0;
+1
View File
@@ -14,6 +14,7 @@ export type AdminSection =
| 'tenants'
| 'users'
| 'userGroups'
| 'auditLogs'
| 'runtime'
| 'accessRules';