This commit is contained in:
@@ -4,6 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { BaseService } from '../BaseService'
|
import { BaseService } from '../BaseService'
|
||||||
|
import {
|
||||||
|
assertAssetActionAllowed,
|
||||||
|
markAssetActionCalled,
|
||||||
|
type AssetRateLimitedAction
|
||||||
|
} from '@/utils/business/apiRateLimit'
|
||||||
import type {
|
import type {
|
||||||
BaseResponse,
|
BaseResponse,
|
||||||
AssetResolveResponse,
|
AssetResolveResponse,
|
||||||
@@ -27,6 +32,17 @@ import type {
|
|||||||
UpdateAssetPackageExpiresAtRequest
|
UpdateAssetPackageExpiresAtRequest
|
||||||
} from '@/types/api'
|
} from '@/types/api'
|
||||||
|
|
||||||
|
const runRateLimitedAssetAction = async <T>(
|
||||||
|
action: AssetRateLimitedAction,
|
||||||
|
identifier: string,
|
||||||
|
request: () => Promise<T>
|
||||||
|
): Promise<T> => {
|
||||||
|
assertAssetActionAllowed(action, identifier)
|
||||||
|
markAssetActionCalled(action, identifier)
|
||||||
|
const response = await request()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
export class AssetService extends BaseService {
|
export class AssetService extends BaseService {
|
||||||
/**
|
/**
|
||||||
* 通过任意标识符查询设备或卡的完整详情
|
* 通过任意标识符查询设备或卡的完整详情
|
||||||
@@ -58,14 +74,16 @@ export class AssetService extends BaseService {
|
|||||||
/**
|
/**
|
||||||
* 主动调网关拉取最新数据后返回
|
* 主动调网关拉取最新数据后返回
|
||||||
* POST /api/admin/assets/:identifier/refresh
|
* POST /api/admin/assets/:identifier/refresh
|
||||||
* 注意:设备有 30 秒冷却期,冷却中调用返回 429
|
* 前端按资产标识限制 5 分钟内只能调用一次
|
||||||
* @param identifier 资产标识符(ICCID 或 VirtualNo)
|
* @param identifier 资产标识符(ICCID 或 VirtualNo)
|
||||||
*/
|
*/
|
||||||
static refreshAsset(identifier: string): Promise<BaseResponse<AssetRefreshResponse>> {
|
static refreshAsset(identifier: string): Promise<BaseResponse<AssetRefreshResponse>> {
|
||||||
return this.post<BaseResponse<AssetRefreshResponse>>(
|
return runRateLimitedAssetAction('refresh', identifier, () =>
|
||||||
`/api/admin/assets/${identifier}/refresh`,
|
this.post<BaseResponse<AssetRefreshResponse>>(
|
||||||
{},
|
`/api/admin/assets/${identifier}/refresh`,
|
||||||
{ timeout: 60000 }
|
{},
|
||||||
|
{ timeout: 60000 }
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +174,13 @@ export class AssetService extends BaseService {
|
|||||||
/**
|
/**
|
||||||
* 复机资产(卡或设备)
|
* 复机资产(卡或设备)
|
||||||
* POST /api/admin/assets/:identifier/start
|
* POST /api/admin/assets/:identifier/start
|
||||||
* 复机成功后设置 1 小时复机保护期(保护期内禁止停机)
|
* 前端按资产标识限制 5 分钟内只能调用一次
|
||||||
* @param identifier 资产标识符(ICCID 或 VirtualNo)
|
* @param identifier 资产标识符(ICCID 或 VirtualNo)
|
||||||
*/
|
*/
|
||||||
static startAsset(identifier: string): Promise<BaseResponse<void>> {
|
static startAsset(identifier: string): Promise<BaseResponse<void>> {
|
||||||
return this.post<BaseResponse<void>>(`/api/admin/assets/${identifier}/start`, {})
|
return runRateLimitedAssetAction('start', identifier, () =>
|
||||||
|
this.post<BaseResponse<void>>(`/api/admin/assets/${identifier}/start`, {})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 资产停用 ==========
|
// ========== 资产停用 ==========
|
||||||
|
|||||||
74
src/utils/business/apiRateLimit.ts
Normal file
74
src/utils/business/apiRateLimit.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,3 +6,4 @@ export * from './format'
|
|||||||
export * from './validate'
|
export * from './validate'
|
||||||
export * from './calculate'
|
export * from './calculate'
|
||||||
export * from './voucher'
|
export * from './voucher'
|
||||||
|
export * from './apiRateLimit'
|
||||||
|
|||||||
@@ -33,8 +33,14 @@
|
|||||||
|
|
||||||
<!-- 操作按钮组 -->
|
<!-- 操作按钮组 -->
|
||||||
<div v-if="hasCardInfo" class="operation-button-group">
|
<div v-if="hasCardInfo" class="operation-button-group">
|
||||||
<ElButton v-permission="'asset_info:sync'" @click="handleRefresh" type="primary" :icon="Refresh" :disabled="refreshDisabled">
|
<ElButton
|
||||||
同步
|
v-permission="'asset_info:sync'"
|
||||||
|
@click="handleRefresh"
|
||||||
|
type="primary"
|
||||||
|
:icon="Refresh"
|
||||||
|
:disabled="refreshDisabled"
|
||||||
|
>
|
||||||
|
{{ refreshButtonText }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,7 +48,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ElCard, ElInput, ElButton } from 'element-plus'
|
import { ElCard, ElInput, ElButton } from 'element-plus'
|
||||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
@@ -57,13 +63,19 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
hasCardInfo?: boolean
|
hasCardInfo?: boolean
|
||||||
refreshDisabled?: boolean
|
refreshDisabled?: boolean
|
||||||
|
refreshRemainingText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
hasCardInfo: false,
|
hasCardInfo: false,
|
||||||
refreshDisabled: false
|
refreshDisabled: false,
|
||||||
|
refreshRemainingText: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const refreshButtonText = computed(() =>
|
||||||
|
props.refreshRemainingText ? `同步(${props.refreshRemainingText})` : '同步'
|
||||||
|
)
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
search: [payload: { identifier: string }]
|
search: [payload: { identifier: string }]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { AssetService } from '@/api/modules'
|
import { AssetService } from '@/api/modules'
|
||||||
|
import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/apiRateLimit'
|
||||||
import type {
|
import type {
|
||||||
AssetBoundCard,
|
AssetBoundCard,
|
||||||
AssetWalletResponse,
|
AssetWalletResponse,
|
||||||
@@ -183,7 +184,7 @@ export function useAssetInfo() {
|
|||||||
ElMessage.error(response.msg || '查询失败')
|
ElMessage.error(response.msg || '查询失败')
|
||||||
cardInfo.value = null
|
cardInfo.value = null
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch {
|
||||||
cardInfo.value = null
|
cardInfo.value = null
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -413,7 +414,7 @@ export function useAssetInfo() {
|
|||||||
* 刷新资产数据(调用refresh接口后重新加载)
|
* 刷新资产数据(调用refresh接口后重新加载)
|
||||||
* @param identifier - 资产标识符
|
* @param identifier - 资产标识符
|
||||||
* @param assetType - 资产类型(card/device)
|
* @param assetType - 资产类型(card/device)
|
||||||
* @returns true if refresh was successful, false if rate limited (429)
|
* @returns true if refresh was successful, false if rate limited
|
||||||
*/
|
*/
|
||||||
const refreshAsset = async (identifier: string, assetType: string): Promise<boolean> => {
|
const refreshAsset = async (identifier: string, assetType: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
@@ -426,10 +427,12 @@ export function useAssetInfo() {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log(error)
|
if (error instanceof FrontendRateLimitError) {
|
||||||
// Check if it's a 429 rate limit error
|
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (error?.response?.status === 429) {
|
if (error?.response?.status === 429) {
|
||||||
ElMessage.warning('操作过于频繁,请30秒后再试')
|
ElMessage.warning('操作过于频繁,请5分钟后再试')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ref } from 'vue'
|
|||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { AssetService, DeviceService } from '@/api/modules'
|
import { AssetService, DeviceService } from '@/api/modules'
|
||||||
|
import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/apiRateLimit'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asset operations composable
|
* Asset operations composable
|
||||||
@@ -38,6 +39,10 @@ export function useAssetOperations(cardInfo: Ref<any>, refreshAssetFn?: () => Pr
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error !== 'cancel') {
|
if (error !== 'cancel') {
|
||||||
|
if (error instanceof FrontendRateLimitError) {
|
||||||
|
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
|
||||||
|
return
|
||||||
|
}
|
||||||
console.error('启用失败:', error)
|
console.error('启用失败:', error)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
ref="assetSearchCardRef"
|
ref="assetSearchCardRef"
|
||||||
:has-card-info="!!cardInfo"
|
:has-card-info="!!cardInfo"
|
||||||
:refresh-disabled="refreshDisabled"
|
:refresh-disabled="refreshDisabled"
|
||||||
|
:refresh-remaining-text="refreshRemainingText"
|
||||||
@search="handleSearch"
|
@search="handleSearch"
|
||||||
@refresh="handleRefresh"
|
@refresh="handleRefresh"
|
||||||
/>
|
/>
|
||||||
@@ -167,11 +168,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElCard, ElEmpty } from 'element-plus'
|
import { ElMessage, ElCard, ElEmpty } from 'element-plus'
|
||||||
import { RoutesAlias } from '@/router/routesAlias'
|
import { RoutesAlias } from '@/router/routesAlias'
|
||||||
import { DeviceService } from '@/api/modules'
|
import { DeviceService } from '@/api/modules'
|
||||||
|
import {
|
||||||
|
formatRemainingTime,
|
||||||
|
FrontendRateLimitError,
|
||||||
|
getAssetActionRateLimitState
|
||||||
|
} from '@/utils/business/apiRateLimit'
|
||||||
|
|
||||||
// 引入子组件
|
// 引入子组件
|
||||||
import AssetSearchCard from './components/AssetSearchCard.vue'
|
import AssetSearchCard from './components/AssetSearchCard.vue'
|
||||||
@@ -311,7 +317,34 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ========== 刷新按钮状态 ==========
|
// ========== 刷新按钮状态 ==========
|
||||||
const refreshDisabled = ref(false)
|
const rateLimitNow = ref(Date.now())
|
||||||
|
const refreshSubmitting = ref(false)
|
||||||
|
let rateLimitTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
|
const refreshRateLimitState = computed(() => {
|
||||||
|
const identifier = cardInfo.value?.identifier
|
||||||
|
if (!identifier) {
|
||||||
|
return {
|
||||||
|
limited: false,
|
||||||
|
remainingMs: 0,
|
||||||
|
retryAt: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getAssetActionRateLimitState('refresh', identifier, rateLimitNow.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshDisabled = computed(
|
||||||
|
() => refreshSubmitting.value || refreshRateLimitState.value.limited
|
||||||
|
)
|
||||||
|
const refreshRemainingText = computed(() =>
|
||||||
|
refreshRateLimitState.value.limited
|
||||||
|
? formatRemainingTime(refreshRateLimitState.value.remainingMs)
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateRateLimitNow = () => {
|
||||||
|
rateLimitNow.value = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 事件处理 ==========
|
// ========== 事件处理 ==========
|
||||||
|
|
||||||
@@ -351,14 +384,14 @@
|
|||||||
* 处理刷新
|
* 处理刷新
|
||||||
*/
|
*/
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
if (!cardInfo.value) return
|
if (!cardInfo.value || refreshDisabled.value) return
|
||||||
const success = await refreshAsset(cardInfo.value.identifier, cardInfo.value.asset_type)
|
|
||||||
if (!success) {
|
try {
|
||||||
// 如果刷新失败(429错误),禁用刷新按钮30秒
|
refreshSubmitting.value = true
|
||||||
refreshDisabled.value = true
|
await refreshAsset(cardInfo.value.identifier, cardInfo.value.asset_type)
|
||||||
setTimeout(() => {
|
} finally {
|
||||||
refreshDisabled.value = false
|
refreshSubmitting.value = false
|
||||||
}, 30000)
|
updateRateLimitNow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +557,10 @@
|
|||||||
if (cardInfo.value)
|
if (cardInfo.value)
|
||||||
await loadRealtimeStatus(cardInfo.value.identifier, cardInfo.value.asset_type)
|
await loadRealtimeStatus(cardInfo.value.identifier, cardInfo.value.asset_type)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error instanceof FrontendRateLimitError) {
|
||||||
|
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
|
||||||
|
return
|
||||||
|
}
|
||||||
console.error('启用绑定卡失败:', error)
|
console.error('启用绑定卡失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -653,9 +690,16 @@
|
|||||||
* URL参数自动加载
|
* URL参数自动加载
|
||||||
*/
|
*/
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
rateLimitTimer = setInterval(updateRateLimitNow, 1000)
|
||||||
autoLoadFromQuery()
|
autoLoadFromQuery()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (rateLimitTimer) {
|
||||||
|
clearInterval(rateLimitTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 监听路由查询参数变化
|
* 监听路由查询参数变化
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1027,6 +1027,7 @@
|
|||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { formatDateTime } from '@/utils/business/format'
|
import { formatDateTime } from '@/utils/business/format'
|
||||||
|
import { formatRemainingTime, FrontendRateLimitError } from '@/utils/business/apiRateLimit'
|
||||||
import type {
|
import type {
|
||||||
StandaloneIotCard,
|
StandaloneIotCard,
|
||||||
StandaloneCardStatus,
|
StandaloneCardStatus,
|
||||||
@@ -2891,6 +2892,10 @@
|
|||||||
await getTableData()
|
await getTableData()
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error instanceof FrontendRateLimitError) {
|
||||||
|
ElMessage.warning(`操作过于频繁,请${formatRemainingTime(error.remainingMs)}后再试`)
|
||||||
|
return
|
||||||
|
}
|
||||||
console.error('启用此卡失败:', error)
|
console.error('启用此卡失败:', error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user