mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 00:02:28 +08:00
Compare commits
6 Commits
v0.1.24
...
41719a00e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41719a00e7 | ||
|
|
b5c0f85dca | ||
|
|
7d6d262ed3 | ||
|
|
e21acd73eb | ||
|
|
702f9bc5f1 | ||
|
|
d0ce798881 |
@@ -4,11 +4,11 @@ import { log } from '@/utils/logger'
|
||||
export function useClipboard() {
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
async function copyToClipboard(text: string, showToast = true): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
success('已复制到剪贴板')
|
||||
if (showToast) success('已复制到剪贴板')
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -25,17 +25,17 @@ export function useClipboard() {
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful) {
|
||||
success('已复制到剪贴板')
|
||||
if (showToast) success('已复制到剪贴板')
|
||||
return true
|
||||
}
|
||||
showError('复制失败,请手动复制')
|
||||
if (showToast) showError('复制失败,请手动复制')
|
||||
return false
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('复制失败:', err)
|
||||
showError('复制失败,请手动选择文本进行复制')
|
||||
if (showToast) showError('复制失败,请手动选择文本进行复制')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,6 +700,7 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -731,6 +732,7 @@ const emit = defineEmits<{
|
||||
'refreshProviders': []
|
||||
}>()
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
interface Props {
|
||||
model: GlobalModelResponse | null
|
||||
@@ -763,16 +765,6 @@ function handleClose() {
|
||||
}
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showSuccess('已复制')
|
||||
} catch {
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '-'
|
||||
|
||||
@@ -433,11 +433,17 @@ const availableGlobalModels = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 计算可添加的上游模型(排除已关联的)
|
||||
// 计算可添加的上游模型(排除已关联的,包括主模型名和映射名称)
|
||||
const availableUpstreamModelsBase = computed(() => {
|
||||
const existingModelNames = new Set(
|
||||
existingModels.value.map(m => m.provider_model_name)
|
||||
)
|
||||
const existingModelNames = new Set<string>()
|
||||
for (const m of existingModels.value) {
|
||||
// 主模型名
|
||||
existingModelNames.add(m.provider_model_name)
|
||||
// 映射名称
|
||||
for (const mapping of m.provider_model_mappings ?? []) {
|
||||
if (mapping.name) existingModelNames.add(mapping.name)
|
||||
}
|
||||
}
|
||||
return upstreamModels.value.filter(m => !existingModelNames.has(m.id))
|
||||
})
|
||||
|
||||
|
||||
@@ -449,7 +449,17 @@ interface UpstreamModelGroup {
|
||||
}
|
||||
|
||||
const groupedAvailableUpstreamModels = computed<UpstreamModelGroup[]>(() => {
|
||||
// 收集当前表单已添加的名称
|
||||
const addedNames = new Set(formData.value.aliases.map(a => a.name.trim()))
|
||||
|
||||
// 收集所有已存在的映射名称(包括主模型名和映射名称)
|
||||
for (const m of props.models) {
|
||||
addedNames.add(m.provider_model_name)
|
||||
for (const mapping of m.provider_model_mappings ?? []) {
|
||||
if (mapping.name) addedNames.add(mapping.name)
|
||||
}
|
||||
}
|
||||
|
||||
const availableModels = filteredUpstreamModels.value.filter(m => !addedNames.has(m.id))
|
||||
|
||||
const groups = new Map<string, UpstreamModelGroup>()
|
||||
|
||||
@@ -661,6 +661,7 @@ import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
|
||||
import {
|
||||
KeyFormDialog,
|
||||
@@ -706,6 +707,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
const loading = ref(false)
|
||||
const provider = ref<any>(null)
|
||||
@@ -1250,16 +1252,6 @@ function getHealthScoreBarColor(score: number): string {
|
||||
return 'bg-red-500 dark:bg-red-400'
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showSuccess('已复制到剪贴板')
|
||||
} catch {
|
||||
showError('复制失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载 Provider 信息
|
||||
async function loadProvider() {
|
||||
if (!props.providerId) return
|
||||
|
||||
@@ -224,6 +224,7 @@ import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image,
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { getProviderModels, testModel, type Model } from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { parseTestModelError } from '@/utils/errorParser'
|
||||
@@ -239,6 +240,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
@@ -257,12 +259,7 @@ const sortedModels = computed(() => {
|
||||
|
||||
// 复制模型 ID 到剪贴板
|
||||
async function copyModelId(modelId: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(modelId)
|
||||
showSuccess('已复制到剪贴板')
|
||||
} catch {
|
||||
showError('复制失败', '错误')
|
||||
}
|
||||
await copyToClipboard(modelId)
|
||||
}
|
||||
|
||||
// 加载模型
|
||||
|
||||
@@ -473,6 +473,7 @@
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Separator from '@/components/ui/separator.vue'
|
||||
@@ -505,6 +506,7 @@ const copiedStates = ref<Record<string, boolean>>({})
|
||||
const viewMode = ref<'compare' | 'formatted' | 'raw'>('compare')
|
||||
const currentExpandDepth = ref(1)
|
||||
const dataSource = ref<'client' | 'provider'>('client')
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const historicalPricing = ref<{
|
||||
input_price: string
|
||||
output_price: string
|
||||
@@ -784,7 +786,7 @@ function copyJsonToClipboard(tabName: string) {
|
||||
}
|
||||
|
||||
if (data) {
|
||||
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
|
||||
copyToClipboard(JSON.stringify(data, null, 2), false)
|
||||
copiedStates.value[tabName] = true
|
||||
setTimeout(() => {
|
||||
copiedStates.value[tabName] = false
|
||||
|
||||
@@ -86,6 +86,34 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isEditMode && form.password.length > 0"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label class="text-sm font-medium">
|
||||
确认新密码 <span class="text-muted-foreground">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
:id="`pwd-confirm-${formNonce}`"
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
:name="`confirm-${formNonce}`"
|
||||
required
|
||||
minlength="6"
|
||||
placeholder="再次输入新密码"
|
||||
class="h-10"
|
||||
/>
|
||||
<p
|
||||
v-if="form.confirmPassword.length > 0 && form.password !== form.confirmPassword"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
两次输入的密码不一致
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="form-email"
|
||||
@@ -423,6 +451,7 @@ const apiFormats = ref<Array<{ value: string; label: string }>>([])
|
||||
const form = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: '',
|
||||
quota: 10,
|
||||
role: 'user' as 'admin' | 'user',
|
||||
@@ -443,6 +472,7 @@ function resetForm() {
|
||||
form.value = {
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: '',
|
||||
quota: 10,
|
||||
role: 'user',
|
||||
@@ -461,6 +491,7 @@ function loadUserData() {
|
||||
form.value = {
|
||||
username: props.user.username,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: props.user.email || '',
|
||||
quota: props.user.quota_usd == null ? 10 : props.user.quota_usd,
|
||||
role: props.user.role,
|
||||
@@ -486,7 +517,9 @@ const isFormValid = computed(() => {
|
||||
const hasUsername = form.value.username.trim().length > 0
|
||||
const hasEmail = form.value.email.trim().length > 0
|
||||
const hasPassword = isEditMode.value || form.value.password.length >= 6
|
||||
return hasUsername && hasEmail && hasPassword
|
||||
// 编辑模式下如果填写了密码,必须确认密码一致
|
||||
const passwordConfirmed = !isEditMode.value || form.value.password.length === 0 || form.value.password === form.value.confirmPassword
|
||||
return hasUsername && hasEmail && hasPassword && passwordConfirmed
|
||||
})
|
||||
|
||||
// 加载访问控制选项
|
||||
|
||||
@@ -650,6 +650,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
|
||||
|
||||
import {
|
||||
@@ -693,6 +694,7 @@ import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
const apiKeys = ref<AdminApiKey[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -927,20 +929,14 @@ function selectKey() {
|
||||
}
|
||||
|
||||
async function copyKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(newKeyValue.value)
|
||||
success('API Key 已复制到剪贴板')
|
||||
} catch {
|
||||
error('复制失败,请手动复制')
|
||||
}
|
||||
await copyToClipboard(newKeyValue.value)
|
||||
}
|
||||
|
||||
async function copyKeyPrefix(apiKey: AdminApiKey) {
|
||||
try {
|
||||
// 调用后端 API 获取完整密钥
|
||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||
await navigator.clipboard.writeText(response.key)
|
||||
success('完整密钥已复制到剪贴板')
|
||||
await copyToClipboard(response.key)
|
||||
} catch (err) {
|
||||
log.error('复制密钥失败:', err)
|
||||
error('复制失败,请重试')
|
||||
|
||||
@@ -46,6 +46,7 @@ const clearingRowAffinityKey = ref<string | null>(null)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const currentTime = ref(Math.floor(Date.now() / 1000))
|
||||
const analysisHoursSelectOpen = ref(false)
|
||||
|
||||
// ==================== 模型映射缓存 ====================
|
||||
|
||||
@@ -1056,7 +1057,7 @@ onBeforeUnmount(() => {
|
||||
<span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔,推荐合适的缓存 TTL</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Select v-model="analysisHours">
|
||||
<Select v-model="analysisHours" v-model:open="analysisHoursSelectOpen">
|
||||
<SelectTrigger class="w-24 sm:w-28 h-8">
|
||||
<SelectValue placeholder="时间段" />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -713,6 +713,7 @@ import ProviderModelFormDialog from '@/features/providers/components/ProviderMod
|
||||
import type { Model } from '@/api/endpoints'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
@@ -743,6 +744,7 @@ import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
@@ -1066,16 +1068,6 @@ function handleRowClick(event: MouseEvent, model: GlobalModelResponse) {
|
||||
selectModel(model)
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
success('已复制')
|
||||
} catch {
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function selectModel(model: GlobalModelResponse) {
|
||||
selectedModel.value = model
|
||||
detailTab.value = 'basic'
|
||||
|
||||
@@ -701,6 +701,7 @@ import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { usageApi, type UsageByUser } from '@/api/usage'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
@@ -748,6 +749,7 @@ import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error } = useToast()
|
||||
const { confirmDanger, confirmWarning } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const usersStore = useUsersStore()
|
||||
|
||||
// 用户表单对话框状态
|
||||
@@ -1001,12 +1003,7 @@ function selectApiKey() {
|
||||
}
|
||||
|
||||
async function copyApiKey() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(newApiKey.value)
|
||||
success('API Key已复制到剪贴板')
|
||||
} catch {
|
||||
error('复制失败,请手动复制')
|
||||
}
|
||||
await copyToClipboard(newApiKey.value)
|
||||
}
|
||||
|
||||
async function closeNewApiKeyDialog() {
|
||||
@@ -1035,8 +1032,7 @@ async function copyFullKey(apiKey: any) {
|
||||
try {
|
||||
// 调用后端 API 获取完整密钥
|
||||
const response = await adminApi.getFullApiKey(apiKey.id)
|
||||
await navigator.clipboard.writeText(response.key)
|
||||
success('完整密钥已复制到剪贴板')
|
||||
await copyToClipboard(response.key)
|
||||
} catch (err: any) {
|
||||
log.error('复制密钥失败:', err)
|
||||
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '复制密钥失败')
|
||||
|
||||
@@ -342,6 +342,7 @@ import {
|
||||
Plus,
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
@@ -370,6 +371,7 @@ import { useRowClick } from '@/composables/useRowClick'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
@@ -565,16 +567,6 @@ function hasTieredPricing(model: PublicGlobalModel): boolean {
|
||||
return (tiered?.tiers?.length || 0) > 1
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
success('已复制')
|
||||
} catch (err) {
|
||||
log.error('复制失败:', err)
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshData()
|
||||
})
|
||||
|
||||
@@ -352,6 +352,7 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
@@ -375,6 +376,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
interface Props {
|
||||
model: PublicGlobalModel | null
|
||||
@@ -408,15 +410,6 @@ function handleClose() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showSuccess('已复制')
|
||||
} catch {
|
||||
showError('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
function getFirstTierPrice(
|
||||
tieredPricing: TieredPricingConfig | undefined | null,
|
||||
priceKey: 'input_price_per_1m' | 'output_price_per_1m' | 'cache_creation_price_per_1m' | 'cache_read_price_per_1m'
|
||||
|
||||
@@ -317,6 +317,7 @@ class UpdateUserRequest(BaseModel):
|
||||
|
||||
username: Optional[str] = Field(None, min_length=1, max_length=50)
|
||||
email: Optional[str] = Field(None, max_length=100)
|
||||
password: Optional[str] = Field(None, min_length=6, max_length=128, description="新密码(留空保持不变)")
|
||||
quota_usd: Optional[float] = Field(None, ge=0)
|
||||
is_active: Optional[bool] = None
|
||||
role: Optional[str] = None
|
||||
|
||||
55
src/services/cache/aware_scheduler.py
vendored
55
src/services/cache/aware_scheduler.py
vendored
@@ -30,6 +30,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union
|
||||
@@ -956,7 +958,16 @@ class CacheAwareScheduler:
|
||||
|
||||
# 获取活跃的 Key 并按 internal_priority + 负载均衡排序
|
||||
active_keys = [key for key in endpoint.api_keys if key.is_active]
|
||||
keys = self._shuffle_keys_by_internal_priority(active_keys, affinity_key)
|
||||
# 检查是否所有 Key 都是 TTL=0(轮换模式)
|
||||
# 如果所有 Key 的 cache_ttl_minutes 都是 0 或 None,则使用随机排序
|
||||
use_random = all(
|
||||
(key.cache_ttl_minutes or 0) == 0 for key in active_keys
|
||||
) if active_keys else False
|
||||
if use_random and len(active_keys) > 1:
|
||||
logger.debug(
|
||||
f" Endpoint {endpoint.id[:8]}... 启用 Key 轮换模式 (TTL=0, {len(active_keys)} keys)"
|
||||
)
|
||||
keys = self._shuffle_keys_by_internal_priority(active_keys, affinity_key, use_random)
|
||||
|
||||
for key in keys:
|
||||
# Key 级别的能力检查(模型级别的能力检查已在上面完成)
|
||||
@@ -1170,6 +1181,7 @@ class CacheAwareScheduler:
|
||||
self,
|
||||
keys: List[ProviderAPIKey],
|
||||
affinity_key: Optional[str] = None,
|
||||
use_random: bool = False,
|
||||
) -> List[ProviderAPIKey]:
|
||||
"""
|
||||
对 API Key 按 internal_priority 分组,同优先级内部基于 affinity_key 进行确定性打乱
|
||||
@@ -1178,10 +1190,12 @@ class CacheAwareScheduler:
|
||||
- 数字越小越优先使用
|
||||
- 同优先级 Key 之间实现负载均衡
|
||||
- 使用 affinity_key 哈希确保同一请求 Key 的请求稳定(避免破坏缓存亲和性)
|
||||
- 当 use_random=True 时,使用随机排序实现轮换(用于 TTL=0 的场景)
|
||||
|
||||
Args:
|
||||
keys: API Key 列表
|
||||
affinity_key: 亲和性标识符(通常为 API Key ID,用于确定性打乱)
|
||||
use_random: 是否使用随机排序(TTL=0 时为 True)
|
||||
|
||||
Returns:
|
||||
排序后的 Key 列表
|
||||
@@ -1198,28 +1212,35 @@ class CacheAwareScheduler:
|
||||
priority = key.internal_priority if key.internal_priority is not None else 999999
|
||||
priority_groups[priority].append(key)
|
||||
|
||||
# 对每个优先级组内的 Key 进行确定性打乱
|
||||
# 对每个优先级组内的 Key 进行打乱
|
||||
result = []
|
||||
for priority in sorted(priority_groups.keys()): # 数字小的优先级高,排前面
|
||||
group_keys = priority_groups[priority]
|
||||
|
||||
if len(group_keys) > 1 and affinity_key:
|
||||
# 改进的哈希策略:为每个 key 计算独立的哈希值
|
||||
import hashlib
|
||||
if len(group_keys) > 1:
|
||||
if use_random:
|
||||
# TTL=0 模式:使用随机排序实现 Key 轮换
|
||||
shuffled = list(group_keys)
|
||||
random.shuffle(shuffled)
|
||||
result.extend(shuffled)
|
||||
elif affinity_key:
|
||||
# 正常模式:使用哈希确定性打乱(保持缓存亲和性)
|
||||
key_scores = []
|
||||
for key in group_keys:
|
||||
# 使用 affinity_key + key.id 的组合哈希
|
||||
hash_input = f"{affinity_key}:{key.id}"
|
||||
hash_value = int(hashlib.sha256(hash_input.encode()).hexdigest()[:16], 16)
|
||||
key_scores.append((hash_value, key))
|
||||
|
||||
key_scores = []
|
||||
for key in group_keys:
|
||||
# 使用 affinity_key + key.id 的组合哈希
|
||||
hash_input = f"{affinity_key}:{key.id}"
|
||||
hash_value = int(hashlib.sha256(hash_input.encode()).hexdigest()[:16], 16)
|
||||
key_scores.append((hash_value, key))
|
||||
|
||||
# 按哈希值排序
|
||||
sorted_group = [key for _, key in sorted(key_scores)]
|
||||
result.extend(sorted_group)
|
||||
# 按哈希值排序
|
||||
sorted_group = [key for _, key in sorted(key_scores)]
|
||||
result.extend(sorted_group)
|
||||
else:
|
||||
# 没有 affinity_key 时按 ID 排序保持稳定性
|
||||
result.extend(sorted(group_keys, key=lambda k: k.id))
|
||||
else:
|
||||
# 单个 Key 或没有 affinity_key 时保持原顺序
|
||||
result.extend(sorted(group_keys, key=lambda k: k.id))
|
||||
# 单个 Key 直接添加
|
||||
result.extend(group_keys)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
"""分布式任务协调器,确保仅有一个 worker 执行特定任务"""
|
||||
"""分布式任务协调器,确保仅有一个 worker 执行特定任务
|
||||
|
||||
锁清理策略:
|
||||
- 单实例模式(默认):启动时使用原子操作清理旧锁并获取新锁
|
||||
- 多实例模式:使用 NX 选项竞争锁,依赖 TTL 处理异常退出
|
||||
|
||||
使用方式:
|
||||
- 默认行为:启动时清理旧锁(适用于单机部署)
|
||||
- 多实例部署:设置 SINGLE_INSTANCE_MODE=false 禁用启动清理
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pathlib
|
||||
import uuid
|
||||
@@ -19,6 +27,10 @@ except ImportError: # pragma: no cover - Windows 环境
|
||||
class StartupTaskCoordinator:
|
||||
"""利用 Redis 或文件锁,保证任务只在单个进程/实例中运行"""
|
||||
|
||||
# 类级别标记:在当前进程中是否已尝试过启动清理
|
||||
# 注意:这在 fork 模式下每个 worker 都是独立的
|
||||
_startup_cleanup_attempted = False
|
||||
|
||||
def __init__(self, redis_client=None, lock_dir: Optional[str] = None):
|
||||
self.redis = redis_client
|
||||
self._tokens: Dict[str, str] = {}
|
||||
@@ -26,6 +38,8 @@ class StartupTaskCoordinator:
|
||||
self._lock_dir = pathlib.Path(lock_dir or os.getenv("TASK_LOCK_DIR", "./.locks"))
|
||||
if not self._lock_dir.exists():
|
||||
self._lock_dir.mkdir(parents=True, exist_ok=True)
|
||||
# 单实例模式:启动时清理旧锁(适用于单机部署,避免残留锁问题)
|
||||
self._single_instance_mode = os.getenv("SINGLE_INSTANCE_MODE", "true").lower() == "true"
|
||||
|
||||
def _redis_key(self, name: str) -> str:
|
||||
return f"task_lock:{name}"
|
||||
@@ -36,12 +50,51 @@ class StartupTaskCoordinator:
|
||||
if self.redis:
|
||||
token = str(uuid.uuid4())
|
||||
try:
|
||||
acquired = await self.redis.set(self._redis_key(name), token, nx=True, ex=ttl)
|
||||
if acquired:
|
||||
self._tokens[name] = token
|
||||
logger.info(f"任务 {name} 通过 Redis 锁独占执行")
|
||||
return True
|
||||
return False
|
||||
if self._single_instance_mode:
|
||||
# 单实例模式:使用 Lua 脚本原子性地"清理旧锁 + 竞争获取"
|
||||
# 只有当锁不存在或成功获取时才返回 1
|
||||
# 这样第一个执行的 worker 会清理旧锁并获取,后续 worker 会正常竞争
|
||||
script = """
|
||||
local key = KEYS[1]
|
||||
local token = ARGV[1]
|
||||
local ttl = tonumber(ARGV[2])
|
||||
local startup_key = KEYS[1] .. ':startup'
|
||||
|
||||
-- 检查是否已有 worker 执行过启动清理
|
||||
local cleaned = redis.call('GET', startup_key)
|
||||
if not cleaned then
|
||||
-- 第一个 worker:删除旧锁,标记已清理
|
||||
redis.call('DEL', key)
|
||||
redis.call('SET', startup_key, '1', 'EX', 60)
|
||||
end
|
||||
|
||||
-- 尝试获取锁(NX 模式)
|
||||
local result = redis.call('SET', key, token, 'NX', 'EX', ttl)
|
||||
if result then
|
||||
return 1
|
||||
end
|
||||
return 0
|
||||
"""
|
||||
result = await self.redis.eval(
|
||||
script, 2,
|
||||
self._redis_key(name), self._redis_key(name),
|
||||
token, ttl
|
||||
)
|
||||
if result == 1:
|
||||
self._tokens[name] = token
|
||||
logger.info(f"任务 {name} 通过 Redis 锁独占执行")
|
||||
return True
|
||||
return False
|
||||
else:
|
||||
# 多实例模式:直接使用 NX 选项竞争锁
|
||||
acquired = await self.redis.set(
|
||||
self._redis_key(name), token, nx=True, ex=ttl
|
||||
)
|
||||
if acquired:
|
||||
self._tokens[name] = token
|
||||
logger.info(f"任务 {name} 通过 Redis 锁独占执行")
|
||||
return True
|
||||
return False
|
||||
except Exception as exc: # pragma: no cover - Redis 异常回退
|
||||
logger.warning(f"Redis 锁获取失败,回退到文件锁: {exc}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user