diff --git a/src/api/modules/asset.ts b/src/api/modules/asset.ts index d4f47cd..541adc7 100644 --- a/src/api/modules/asset.ts +++ b/src/api/modules/asset.ts @@ -4,6 +4,11 @@ */ import { BaseService } from '../BaseService' +import { + assertAssetActionAllowed, + markAssetActionCalled, + type AssetRateLimitedAction +} from '@/utils/business/apiRateLimit' import type { BaseResponse, AssetResolveResponse, @@ -27,6 +32,17 @@ import type { UpdateAssetPackageExpiresAtRequest } from '@/types/api' +const runRateLimitedAssetAction = async ( + action: AssetRateLimitedAction, + identifier: string, + request: () => Promise +): Promise => { + assertAssetActionAllowed(action, identifier) + markAssetActionCalled(action, identifier) + const response = await request() + return response +} + export class AssetService extends BaseService { /** * 通过任意标识符查询设备或卡的完整详情 @@ -58,14 +74,16 @@ export class AssetService extends BaseService { /** * 主动调网关拉取最新数据后返回 * POST /api/admin/assets/:identifier/refresh - * 注意:设备有 30 秒冷却期,冷却中调用返回 429 + * 前端按资产标识限制 5 分钟内只能调用一次 * @param identifier 资产标识符(ICCID 或 VirtualNo) */ static refreshAsset(identifier: string): Promise> { - return this.post>( - `/api/admin/assets/${identifier}/refresh`, - {}, - { timeout: 60000 } + return runRateLimitedAssetAction('refresh', identifier, () => + this.post>( + `/api/admin/assets/${identifier}/refresh`, + {}, + { timeout: 60000 } + ) ) } @@ -156,11 +174,13 @@ export class AssetService extends BaseService { /** * 复机资产(卡或设备) * POST /api/admin/assets/:identifier/start - * 复机成功后设置 1 小时复机保护期(保护期内禁止停机) + * 前端按资产标识限制 5 分钟内只能调用一次 * @param identifier 资产标识符(ICCID 或 VirtualNo) */ static startAsset(identifier: string): Promise> { - return this.post>(`/api/admin/assets/${identifier}/start`, {}) + return runRateLimitedAssetAction('start', identifier, () => + this.post>(`/api/admin/assets/${identifier}/start`, {}) + ) } // ========== 资产停用 ========== diff --git a/src/utils/business/apiRateLimit.ts b/src/utils/business/apiRateLimit.ts new file mode 100644 index 0000000..9603e5d --- /dev/null +++ b/src/utils/business/apiRateLimit.ts @@ -0,0 +1,74 @@ +const FIVE_MINUTES_MS = 5 * 60 * 1000 +const STORAGE_KEY_PREFIX = 'asset-action-rate-limit' + +export type AssetRateLimitedAction = 'refresh' | 'start' + +export class FrontendRateLimitError extends Error { + readonly remainingMs: number + readonly retryAt: number + + constructor(remainingMs: number, retryAt: number) { + super(`操作过于频繁,请${formatRemainingTime(remainingMs)}后再试`) + this.name = 'FrontendRateLimitError' + this.remainingMs = remainingMs + this.retryAt = retryAt + } +} + +const getStorageKey = (action: AssetRateLimitedAction, identifier: string) => + `${STORAGE_KEY_PREFIX}:${action}:${identifier}` + +const getTimestamp = (key: string): number => { + const value = Number(localStorage.getItem(key)) + return Number.isFinite(value) ? value : 0 +} + +export const formatRemainingTime = (remainingMs: number): string => { + const totalSeconds = Math.max(1, Math.ceil(remainingMs / 1000)) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + + if (minutes <= 0) return `${seconds}秒` + if (seconds === 0) return `${minutes}分钟` + return `${minutes}分${seconds}秒` +} + +export const getAssetActionRateLimitState = ( + action: AssetRateLimitedAction, + identifier: string, + now: number = Date.now() +) => { + const key = getStorageKey(action, identifier) + const lastCalledAt = getTimestamp(key) + const retryAt = lastCalledAt + FIVE_MINUTES_MS + const remainingMs = Math.max(0, retryAt - now) + + if (remainingMs <= 0 && lastCalledAt > 0) { + localStorage.removeItem(key) + } + + return { + limited: remainingMs > 0, + remainingMs, + retryAt + } +} + +export const markAssetActionCalled = ( + action: AssetRateLimitedAction, + identifier: string, + now: number = Date.now() +) => { + localStorage.setItem(getStorageKey(action, identifier), String(now)) +} + +export const assertAssetActionAllowed = ( + action: AssetRateLimitedAction, + identifier: string, + now: number = Date.now() +) => { + const state = getAssetActionRateLimitState(action, identifier, now) + if (state.limited) { + throw new FrontendRateLimitError(state.remainingMs, state.retryAt) + } +} diff --git a/src/utils/business/index.ts b/src/utils/business/index.ts index f53d73a..ad7bb1b 100644 --- a/src/utils/business/index.ts +++ b/src/utils/business/index.ts @@ -6,3 +6,4 @@ export * from './format' export * from './validate' export * from './calculate' export * from './voucher' +export * from './apiRateLimit' diff --git a/src/views/asset-management/asset-information/components/AssetSearchCard.vue b/src/views/asset-management/asset-information/components/AssetSearchCard.vue index 459223a..fb3f642 100644 --- a/src/views/asset-management/asset-information/components/AssetSearchCard.vue +++ b/src/views/asset-management/asset-information/components/AssetSearchCard.vue @@ -33,8 +33,14 @@
- - 同步 + + {{ refreshButtonText }}
@@ -42,7 +48,7 @@