feat(admin): make user rate limits configurable via admin UI

Add rate_limit_settings table and corresponding admin permissions
Replace static user rate limits with dynamic in-memory counters
Add interface in admin panel to configure rate limit policies
This commit is contained in:
2026-05-03 22:11:41 +08:00
parent b0e2464c24
commit deb0b54e71
7 changed files with 491 additions and 153 deletions

View File

@@ -46,6 +46,10 @@ import {
type Permission,
type PermissionPayload,
type Pokemon,
type RateLimitPolicyKey,
type RateLimitPolicySettings,
type RateLimitSettings,
type RateLimitSettingsPayload,
type Recipe,
type RoleDetail,
type RolePayload,
@@ -59,6 +63,7 @@ type AdminTab =
| 'users'
| 'roles'
| 'permissions'
| 'rateLimits'
| 'aiModeration'
| 'config'
| 'languages'
@@ -77,11 +82,36 @@ type EditableConfig = (NamedEntity | Skill | LifeCategory | GameVersion) & {
isRateable?: boolean;
changeLog?: string;
};
type RateLimitPolicyForm = {
maxRequests: number;
timeWindowMinutes: number;
cooldownSeconds: number;
};
const rateLimitPolicyKeys: RateLimitPolicyKey[] = [
'accountWrite',
'adminWrite',
'wikiWrite',
'communityWrite',
'communityReaction',
'upload',
'fetch'
];
const defaultRateLimitPolicies: Record<RateLimitPolicyKey, RateLimitPolicySettings> = {
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
communityReaction: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 1 },
communityWrite: { maxRequests: 60, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
fetch: { maxRequests: 60, timeWindowSeconds: 10 * 60, cooldownSeconds: 1 },
upload: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 30 },
wikiWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 }
};
const adminTabIcons: Record<AdminTab, AppIcon> = {
users: iconProfile,
roles: iconKey,
permissions: iconKey,
rateLimits: iconAdmin,
aiModeration: iconAdmin,
config: iconAdmin,
languages: iconTranslate,
@@ -128,7 +158,8 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
items: [
{ key: 'users', label: t('pages.admin.users'), permission: 'admin.users.read' },
{ key: 'roles', label: t('pages.admin.roles'), permission: 'admin.roles.read' },
{ key: 'permissions', label: t('pages.admin.permissions'), permission: 'admin.permissions.read' }
{ key: 'permissions', label: t('pages.admin.permissions'), permission: 'admin.permissions.read' },
{ key: 'rateLimits', label: t('pages.admin.rateLimits'), permission: 'admin.rate-limits.read' }
]
}
];
@@ -168,6 +199,7 @@ const recipeRows = ref<Recipe[]>([]);
const habitatRows = ref<Habitat[]>([]);
const wordingRows = ref<SystemWording[]>([]);
const aiModerationSettings = ref<AiModerationSettings | null>(null);
const rateLimitSettings = ref<RateLimitSettings | null>(null);
const currentUser = ref<AuthUser | null>(null);
const busy = ref(false);
const contentLoading = ref(false);
@@ -194,6 +226,18 @@ const aiModerationForm = ref({
apiKey: '',
clearApiKey: false
});
const rateLimitForm = ref<Record<RateLimitPolicyKey, RateLimitPolicyForm>>(
Object.fromEntries(
rateLimitPolicyKeys.map((policy) => [
policy,
{
maxRequests: defaultRateLimitPolicies[policy].maxRequests,
timeWindowMinutes: defaultRateLimitPolicies[policy].timeWindowSeconds / 60,
cooldownSeconds: defaultRateLimitPolicies[policy].cooldownSeconds
}
])
) as Record<RateLimitPolicyKey, RateLimitPolicyForm>
);
const userRoleForm = ref({ userId: 0, roleIds: [] as number[] });
const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true });
const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] });
@@ -299,6 +343,15 @@ const aiModerationAuthModeOptions = computed<Array<{ value: AiModerationAuthMode
{ value: 'bearer-token', label: t('pages.admin.aiModerationAuthBearer') },
{ value: 'query-key', label: t('pages.admin.aiModerationAuthQueryKey') }
]);
const rateLimitPolicyOptions = computed<Array<{ value: RateLimitPolicyKey; label: string }>>(() => [
{ value: 'accountWrite', label: t('pages.admin.rateLimitAccountWrite') },
{ value: 'adminWrite', label: t('pages.admin.rateLimitAdminWrite') },
{ value: 'wikiWrite', label: t('pages.admin.rateLimitWikiWrite') },
{ value: 'communityWrite', label: t('pages.admin.rateLimitCommunityWrite') },
{ value: 'communityReaction', label: t('pages.admin.rateLimitCommunityReaction') },
{ value: 'upload', label: t('pages.admin.rateLimitUpload') },
{ value: 'fetch', label: t('pages.admin.rateLimitFetch') }
]);
const filteredWordingRows = computed(() =>
wordingRows.value.filter((item) => {
if (wordingModule.value && item.module !== wordingModule.value) return false;
@@ -422,6 +475,22 @@ function resetAiModerationForm(settings: AiModerationSettings | null = aiModerat
};
}
function resetRateLimitForm(settings: RateLimitSettings | null = rateLimitSettings.value) {
rateLimitForm.value = Object.fromEntries(
rateLimitPolicyKeys.map((policy) => {
const policySettings = settings?.policies[policy] ?? defaultRateLimitPolicies[policy];
return [
policy,
{
maxRequests: policySettings.maxRequests,
timeWindowMinutes: Math.max(1, Math.round(policySettings.timeWindowSeconds / 60)),
cooldownSeconds: policySettings.cooldownSeconds
}
];
})
) as Record<RateLimitPolicyKey, RateLimitPolicyForm>;
}
function resetUserRoleForm() {
userRoleForm.value = { userId: 0, roleIds: [] };
}
@@ -854,6 +923,11 @@ async function loadAiModerationSettings() {
resetAiModerationForm(aiModerationSettings.value);
}
async function loadRateLimitSettings() {
rateLimitSettings.value = await api.rateLimitSettings();
resetRateLimitForm(rateLimitSettings.value);
}
async function reloadWordings() {
await run(loadWordings);
}
@@ -893,6 +967,24 @@ async function saveAiModerationSettings() {
});
}
async function saveRateLimitSettings() {
await run(async () => {
const policies = Object.fromEntries(
rateLimitPolicyKeys.map((policy) => [
policy,
{
maxRequests: rateLimitForm.value[policy].maxRequests,
timeWindowSeconds: rateLimitForm.value[policy].timeWindowMinutes * 60,
cooldownSeconds: rateLimitForm.value[policy].cooldownSeconds
}
])
) as RateLimitSettingsPayload['policies'];
rateLimitSettings.value = await api.updateRateLimitSettings({ policies });
resetRateLimitForm(rateLimitSettings.value);
});
}
async function saveUserRoles() {
await run(async () => {
userRows.value = await api.updateAdminUserRoles(userRoleForm.value.userId, userRoleForm.value.roleIds);
@@ -955,6 +1047,7 @@ async function loadCurrentTab(showSkeleton = false) {
if (activeTab.value === 'users') await loadUsers();
if (activeTab.value === 'roles') await loadRoles();
if (activeTab.value === 'permissions') await loadPermissions();
if (activeTab.value === 'rateLimits') await loadRateLimitSettings();
if (activeTab.value === 'languages') await loadLanguages();
if (activeTab.value === 'wordings') await loadWordings();
if (activeTab.value === 'aiModeration') await loadAiModerationSettings();
@@ -1235,6 +1328,64 @@ onMounted(() => {
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'rateLimits'" class="detail-section">
<form class="modal-edit-form" @submit.prevent="saveRateLimitSettings">
<div class="detail-section__header">
<h2>{{ t('pages.admin.rateLimits') }}</h2>
<button v-if="can('admin.rate-limits.update')" class="ui-button ui-button--primary ui-button--small" type="submit" :disabled="busy">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
</div>
<div class="rate-limit-list">
<div v-for="policy in rateLimitPolicyOptions" :key="policy.value" class="rate-limit-row">
<h3>{{ policy.label }}</h3>
<div class="rate-limit-fields">
<div class="field">
<label :for="`rate-limit-${policy.value}-max`">{{ t('pages.admin.rateLimitMaxRequests') }}</label>
<input
:id="`rate-limit-${policy.value}-max`"
v-model.number="rateLimitForm[policy.value].maxRequests"
type="number"
min="1"
max="5000"
step="1"
:disabled="busy || !can('admin.rate-limits.update')"
required
/>
</div>
<div class="field">
<label :for="`rate-limit-${policy.value}-window`">{{ t('pages.admin.rateLimitWindowMinutes') }}</label>
<input
:id="`rate-limit-${policy.value}-window`"
v-model.number="rateLimitForm[policy.value].timeWindowMinutes"
type="number"
min="1"
max="1440"
step="1"
:disabled="busy || !can('admin.rate-limits.update')"
required
/>
</div>
<div class="field">
<label :for="`rate-limit-${policy.value}-cooldown`">{{ t('pages.admin.rateLimitCooldownSeconds') }}</label>
<input
:id="`rate-limit-${policy.value}-cooldown`"
v-model.number="rateLimitForm[policy.value].cooldownSeconds"
type="number"
min="0"
max="3600"
step="1"
:disabled="busy || !can('admin.rate-limits.update')"
required
/>
</div>
</div>
</div>
</div>
</form>
</section>
<section v-else-if="canEdit && activeTab === 'checklist'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.checklist') }}</h2>