Merge branch 'pr-140'

This commit is contained in:
fawney19
2026-02-04 12:17:46 +08:00
5 changed files with 600 additions and 23 deletions

View File

@@ -490,12 +490,13 @@
<template #actions> <template #actions>
<Button <Button
size="sm" size="sm"
:disabled="checkinTimeLoading || !hasCheckinTimeChanged" :disabled="schedulerConfigLoading || !hasSchedulerConfigChanges"
@click="handleCheckinTimeSave" @click="handleSchedulerConfigSave"
> >
{{ checkinTimeLoading ? '保存中...' : '保存' }} {{ schedulerConfigLoading ? '保存中...' : '保存' }}
</Button> </Button>
</template> </template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Switch <Switch
@@ -564,6 +565,100 @@
</p> </p>
</div> </div>
</div> </div>
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex items-center space-x-2">
<Switch
id="enable-user-quota-reset"
:model-value="systemConfig.enable_user_quota_reset"
@update:model-value="handleUserQuotaResetToggle"
/>
<div>
<Label
for="enable-user-quota-reset"
class="cursor-pointer"
>
启用用户配额自动重置
</Label>
<p class="text-xs text-muted-foreground">
每天定时触发,按周期执行
</p>
</div>
</div>
<div
v-if="systemConfig.enable_user_quota_reset"
class="grid grid-cols-1 md:grid-cols-2 gap-6"
>
<div>
<Label class="block text-sm font-medium">
执行时间
</Label>
<div class="mt-1 flex items-center gap-2">
<Select
v-model:open="userQuotaResetHourSelectOpen"
:model-value="userQuotaResetHour"
@update:model-value="(val: string) => updateUserQuotaResetTime(val, userQuotaResetMinute)"
>
<SelectTrigger class="w-20">
<SelectValue placeholder="" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="h in 24"
:key="h - 1"
:value="String(h - 1).padStart(2, '0')"
>
{{ String(h - 1).padStart(2, '0') }}
</SelectItem>
</SelectContent>
</Select>
<span class="text-muted-foreground">:</span>
<Select
v-model:open="userQuotaResetMinuteSelectOpen"
:model-value="userQuotaResetMinute"
@update:model-value="(val: string) => updateUserQuotaResetTime(userQuotaResetHour, val)"
>
<SelectTrigger class="w-20">
<SelectValue placeholder="" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="m in 60"
:key="m - 1"
:value="String(m - 1).padStart(2, '0')"
>
{{ String(m - 1).padStart(2, '0') }}
</SelectItem>
</SelectContent>
</Select>
</div>
<p class="mt-1 text-xs text-muted-foreground">
每天定时执行24小时制
</p>
</div>
<div>
<Label class="block text-sm font-medium">
重置周期
</Label>
<div class="mt-1 flex items-center gap-2 flex-wrap">
<span class="text-xs text-muted-foreground">每</span>
<Input
v-model.number="systemConfig.user_quota_reset_interval_days"
type="number"
min="1"
step="1"
class="w-24"
/>
<span class="text-xs text-muted-foreground">天</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">
滚动计算:距离上次成功执行满 N 天后才会再次执行
</p>
</div>
</div>
</div>
</CardSection> </CardSection>
<!-- 系统版本信息 --> <!-- 系统版本信息 -->
@@ -982,6 +1077,9 @@ interface SystemConfig {
// 定时任务 // 定时任务
enable_provider_checkin: boolean enable_provider_checkin: boolean
provider_checkin_time: string provider_checkin_time: string
enable_user_quota_reset: boolean
user_quota_reset_time: string
user_quota_reset_interval_days: number
} }
const basicConfigLoading = ref(false) const basicConfigLoading = ref(false)
@@ -1039,6 +1137,9 @@ const systemConfig = ref<SystemConfig>({
// 定时任务 // 定时任务
enable_provider_checkin: true, enable_provider_checkin: true,
provider_checkin_time: '01:05', provider_checkin_time: '01:05',
enable_user_quota_reset: false,
user_quota_reset_time: '05:00',
user_quota_reset_interval_days: 1,
}) })
// 原始配置值(用于检测变动) // 原始配置值(用于检测变动)
@@ -1147,6 +1248,9 @@ async function loadSystemConfig() {
// 定时任务 // 定时任务
'enable_provider_checkin', 'enable_provider_checkin',
'provider_checkin_time', 'provider_checkin_time',
'enable_user_quota_reset',
'user_quota_reset_time',
'user_quota_reset_interval_days',
] ]
for (const key of configs) { for (const key of configs) {
@@ -1163,6 +1267,9 @@ async function loadSystemConfig() {
originalConfig.value = JSON.parse(JSON.stringify(systemConfig.value)) originalConfig.value = JSON.parse(JSON.stringify(systemConfig.value))
// 初始化签到时间的原始值(用于回滚) // 初始化签到时间的原始值(用于回滚)
previousCheckinTime.value = systemConfig.value.provider_checkin_time previousCheckinTime.value = systemConfig.value.provider_checkin_time
// 初始化配额重置时间的原始值(用于回滚)
previousUserQuotaResetTime.value = systemConfig.value.user_quota_reset_time
previousUserQuotaResetIntervalDays.value = systemConfig.value.user_quota_reset_interval_days
} catch (err) { } catch (err) {
error('加载系统配置失败') error('加载系统配置失败')
log.error('加载系统配置失败:', err) log.error('加载系统配置失败:', err)
@@ -1305,9 +1412,8 @@ async function handleProviderCheckinToggle(enabled: boolean) {
} }
} }
// 签到时间相关
const previousCheckinTime = ref('') const previousCheckinTime = ref('')
const checkinTimeLoading = ref(false) const schedulerConfigLoading = ref(false)
const checkinHourSelectOpen = ref(false) const checkinHourSelectOpen = ref(false)
const checkinMinuteSelectOpen = ref(false) const checkinMinuteSelectOpen = ref(false)
@@ -1334,29 +1440,138 @@ const hasCheckinTimeChanged = computed(() => {
return systemConfig.value.provider_checkin_time !== previousCheckinTime.value return systemConfig.value.provider_checkin_time !== previousCheckinTime.value
}) })
async function handleCheckinTimeSave() { async function handleUserQuotaResetToggle(enabled: boolean) {
const newTime = systemConfig.value.provider_checkin_time const previousValue = systemConfig.value.enable_user_quota_reset
systemConfig.value.enable_user_quota_reset = enabled
// 验证时间格式
if (!newTime || !/^\d{2}:\d{2}$/.test(newTime)) {
error('请输入有效的时间格式 (HH:MM)')
return
}
checkinTimeLoading.value = true
try { try {
await adminApi.updateSystemConfig( await adminApi.updateSystemConfig(
'provider_checkin_time', 'enable_user_quota_reset',
newTime, enabled,
'Provider 自动签到执行时间HH:MM 格式)' '是否启用用户配额自动重置任务'
) )
previousCheckinTime.value = newTime success(enabled ? '已启用用户配额自动重置' : '已禁用用户配额自动重置')
success(`签到时间已设置为 ${newTime}`)
} catch (err) { } catch (err) {
error('保存签到时间失败') error('保存配置失败')
log.error('保存签到时间失败:', err) log.error('保存用户配额自动重置配置失败:', err)
// 回滚状态
systemConfig.value.enable_user_quota_reset = previousValue
}
}
// 用户配额重置时间相关
const previousUserQuotaResetTime = ref('')
const userQuotaResetHourSelectOpen = ref(false)
const userQuotaResetMinuteSelectOpen = ref(false)
const previousUserQuotaResetIntervalDays = ref(1)
const userQuotaResetHour = computed(() => {
const time = systemConfig.value.user_quota_reset_time
if (!time || !time.includes(':')) return '05'
return time.split(':')[0]
})
const userQuotaResetMinute = computed(() => {
const time = systemConfig.value.user_quota_reset_time
if (!time || !time.includes(':')) return '00'
return time.split(':')[1]
})
function updateUserQuotaResetTime(hour: string, minute: string) {
systemConfig.value.user_quota_reset_time = `${hour}:${minute}`
}
const hasUserQuotaResetTimeChanged = computed(() => {
return systemConfig.value.user_quota_reset_time !== previousUserQuotaResetTime.value
})
const hasUserQuotaResetIntervalChanged = computed(() => {
return systemConfig.value.user_quota_reset_interval_days !== previousUserQuotaResetIntervalDays.value
})
const hasSchedulerConfigChanges = computed(() => {
return (
hasCheckinTimeChanged.value ||
hasUserQuotaResetTimeChanged.value ||
hasUserQuotaResetIntervalChanged.value
)
})
async function handleSchedulerConfigSave() {
const configItems: Array<{ key: string, value: any, description: string, onSuccess: () => void }> = []
if (hasCheckinTimeChanged.value) {
const newTime = systemConfig.value.provider_checkin_time
if (!newTime || !/^\d{2}:\d{2}$/.test(newTime)) {
error('请输入有效的时间格式 (HH:MM)')
return
}
configItems.push({
key: 'provider_checkin_time',
value: newTime,
description: 'Provider 自动签到执行时间HH:MM 格式)',
onSuccess: () => {
previousCheckinTime.value = newTime
}
})
}
if (hasUserQuotaResetTimeChanged.value) {
const newTime = systemConfig.value.user_quota_reset_time
if (!newTime || !/^\d{2}:\d{2}$/.test(newTime)) {
error('请输入有效的时间格式 (HH:MM)')
return
}
configItems.push({
key: 'user_quota_reset_time',
value: newTime,
description: '用户配额自动重置执行时间HH:MM 格式)',
onSuccess: () => {
previousUserQuotaResetTime.value = newTime
}
})
}
if (hasUserQuotaResetIntervalChanged.value) {
let intervalDays = Number(systemConfig.value.user_quota_reset_interval_days)
if (!Number.isFinite(intervalDays) || intervalDays < 1) intervalDays = 1
intervalDays = Math.trunc(intervalDays)
systemConfig.value.user_quota_reset_interval_days = intervalDays
configItems.push({
key: 'user_quota_reset_interval_days',
value: intervalDays,
description: '用户配额重置周期(天数),滚动计算',
onSuccess: () => {
previousUserQuotaResetIntervalDays.value = intervalDays
}
})
}
if (configItems.length === 0) return
schedulerConfigLoading.value = true
const failedKeys: string[] = []
try {
for (const item of configItems) {
try {
await adminApi.updateSystemConfig(item.key, item.value, item.description)
item.onSuccess()
} catch (err) {
failedKeys.push(item.key)
log.error(`保存定时任务配置失败: ${item.key}`, err)
}
}
if (failedKeys.length > 0) {
error(`部分配置保存失败: ${failedKeys.join(', ')}`)
return
}
success('定时任务配置已保存')
} finally { } finally {
checkinTimeLoading.value = false schedulerConfigLoading.value = false
} }
} }

View File

@@ -624,6 +624,16 @@ class AdminSetSystemConfigAdapter(AdminApiAdapter):
except Exception as e: except Exception as e:
logger.warning(f"更新签到任务时间失败: {e}") logger.warning(f"更新签到任务时间失败: {e}")
# 如果更新的是用户配额重置任务时间,动态更新调度器
if self.key == "user_quota_reset_time" and value:
try:
from src.services.system.maintenance_scheduler import get_maintenance_scheduler
scheduler = get_maintenance_scheduler()
scheduler.update_user_quota_reset_time(value)
except Exception as e:
logger.warning(f"更新用户配额重置任务时间失败: {e}")
# 返回时不暴露加密后的值 # 返回时不暴露加密后的值
display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value

View File

@@ -123,6 +123,18 @@ class SystemConfigService:
"value": "01:05", "value": "01:05",
"description": "Provider 自动签到执行时间HH:MM 格式24小时制", "description": "Provider 自动签到执行时间HH:MM 格式24小时制",
}, },
"enable_user_quota_reset": {
"value": False,
"description": "是否启用用户配额自动重置任务(按配置时间触发,按周期执行)",
},
"user_quota_reset_time": {
"value": "05:00",
"description": "用户配额自动重置执行时间HH:MM 格式24小时制",
},
"user_quota_reset_interval_days": {
"value": 1,
"description": "用户配额重置周期(天数)",
},
"provider_priority_mode": { "provider_priority_mode": {
"value": "provider", "value": "provider",
"description": "优先级策略provider(提供商优先模式) 或 global_key(全局Key优先模式)", "description": "优先级策略provider(提供商优先模式) 或 global_key(全局Key优先模式)",

View File

@@ -38,6 +38,8 @@ class MaintenanceScheduler:
# 签到任务的 job_id # 签到任务的 job_id
CHECKIN_JOB_ID = "provider_checkin" CHECKIN_JOB_ID = "provider_checkin"
# 用户配额重置任务的 job_id
USER_QUOTA_RESET_JOB_ID = "user_quota_reset"
def __init__(self) -> None: def __init__(self) -> None:
self.running = False self.running = False
@@ -57,6 +59,19 @@ class MaintenanceScheduler:
finally: finally:
db.close() db.close()
def _get_user_quota_reset_time(self) -> tuple[int, int]:
"""获取用户配额重置任务的执行时间
Returns:
(hour, minute) 元组
"""
db = create_session()
try:
time_str = SystemConfigService.get_config(db, "user_quota_reset_time", "05:00")
return self._parse_user_quota_reset_time_string(time_str)
finally:
db.close()
@staticmethod @staticmethod
def _parse_time_string(time_str: str) -> tuple[int, int]: def _parse_time_string(time_str: str) -> tuple[int, int]:
"""解析时间字符串为 (hour, minute) 元组 """解析时间字符串为 (hour, minute) 元组
@@ -80,6 +95,26 @@ class MaintenanceScheduler:
except (ValueError, IndexError): except (ValueError, IndexError):
return (1, 5) return (1, 5)
@staticmethod
def _parse_user_quota_reset_time_string(time_str: str) -> tuple[int, int]:
"""解析用户配额重置时间字符串为 (hour, minute) 元组
Returns:
(hour, minute) 元组,解析失败返回默认值 (5, 0)
"""
try:
if not time_str or ":" not in time_str:
return (5, 0)
parts = time_str.split(":")
hour = int(parts[0])
minute = int(parts[1])
# 验证范围
if 0 <= hour <= 23 and 0 <= minute <= 59:
return (hour, minute)
return (5, 0)
except (ValueError, IndexError):
return (5, 0)
def update_checkin_time(self, time_str: str) -> bool: def update_checkin_time(self, time_str: str) -> bool:
"""更新签到任务的执行时间 """更新签到任务的执行时间
@@ -103,6 +138,29 @@ class MaintenanceScheduler:
return success return success
def update_user_quota_reset_time(self, time_str: str) -> bool:
"""更新用户配额重置任务的执行时间
Args:
time_str: HH:MM 格式的时间字符串
Returns:
是否成功更新
"""
hour, minute = self._parse_user_quota_reset_time_string(time_str)
scheduler = get_scheduler()
success = scheduler.reschedule_cron_job(
self.USER_QUOTA_RESET_JOB_ID,
hour=hour,
minute=minute,
)
if success:
logger.info(f"用户配额重置任务时间已更新为: {hour:02d}:{minute:02d}")
return success
def get_checkin_job_info(self) -> dict | None: def get_checkin_job_info(self) -> dict | None:
"""获取签到任务的信息 """获取签到任务的信息
@@ -203,6 +261,16 @@ class MaintenanceScheduler:
name="Provider签到", name="Provider签到",
) )
# 用户配额重置任务 - 根据配置时间执行(按周期配置决定是否执行)
quota_reset_hour, quota_reset_minute = self._get_user_quota_reset_time()
scheduler.add_cron_job(
self._scheduled_user_quota_reset,
hour=quota_reset_hour,
minute=quota_reset_minute,
job_id=self.USER_QUOTA_RESET_JOB_ID,
name="用户配额自动重置",
)
# 启动时执行一次初始化任务 # 启动时执行一次初始化任务
asyncio.create_task(self._run_startup_tasks()) asyncio.create_task(self._run_startup_tasks())
@@ -274,6 +342,10 @@ class MaintenanceScheduler:
"""Provider 签到任务(定时调用)""" """Provider 签到任务(定时调用)"""
await self._perform_provider_checkin() await self._perform_provider_checkin()
async def _scheduled_user_quota_reset(self) -> None:
"""用户配额重置任务(定时调用)"""
await self._perform_user_quota_reset()
# ========== 实际任务实现 ========== # ========== 实际任务实现 ==========
async def _perform_stats_aggregation(self, backfill: bool = False) -> None: async def _perform_stats_aggregation(self, backfill: bool = False) -> None:
@@ -677,6 +749,108 @@ class MaintenanceScheduler:
if db is not None: if db is not None:
db.close() db.close()
async def _perform_user_quota_reset(self) -> None:
"""执行用户配额自动重置任务
适用范围:
- 未删除is_deleted=false
- 仅对 quota_usd != NULL 的用户生效
"""
db = create_session()
try:
# 检查是否启用用户配额重置
if not SystemConfigService.get_config(db, "enable_user_quota_reset", False):
logger.info("用户配额自动重置已禁用,跳过任务")
return
# 重置周期(天数),不限制上限
interval_value = SystemConfigService.get_config(db, "user_quota_reset_interval_days", 1)
try:
interval_days = int(interval_value)
except Exception:
interval_days = 1
if interval_days < 1:
interval_days = 1
# 滚动计算根据上次执行日APP_TIMEZONE判断是否到期
last_reset_at = SystemConfigService.get_config(db, "user_quota_last_reset_at")
should_run = True
if last_reset_at:
last_dt: datetime | None = None
try:
if isinstance(last_reset_at, str):
last_dt = datetime.fromisoformat(last_reset_at)
except Exception:
last_dt = None
if last_dt is None:
logger.warning("user_quota_last_reset_at 格式无效,视为需要执行一次")
else:
if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=timezone.utc)
from zoneinfo import ZoneInfo
from src.services.system.scheduler import APP_TIMEZONE
tz = ZoneInfo(APP_TIMEZONE)
now_local = datetime.now(tz)
last_local_date = last_dt.astimezone(tz).date()
days_since_reset = (now_local.date() - last_local_date).days
if days_since_reset < 0:
logger.warning(
"user_quota_last_reset_at 在未来,跳过本次用户配额自动重置"
)
should_run = False
elif days_since_reset < interval_days:
logger.info(
f"用户配额自动重置未到周期,跳过任务({days_since_reset}/{interval_days}天)"
)
should_run = False
if not should_run:
return
from src.models.database import User as DBUser
now_utc = datetime.now(timezone.utc)
reset_count = (
db.query(DBUser)
.filter(
DBUser.is_deleted.is_(False),
DBUser.quota_usd.isnot(None),
)
.update(
{
DBUser.used_usd: 0.0,
DBUser.updated_at: now_utc,
},
synchronize_session=False,
)
)
db.commit()
# 记录 last_reset_at成功执行后更新滚动计算用
SystemConfigService.set_config(
db,
"user_quota_last_reset_at",
now_utc.isoformat(),
"用户配额自动重置的上次执行时间UTC内部使用",
)
logger.info(f"用户配额自动重置完成: interval_days={interval_days}, 重置用户数={reset_count}")
except Exception as e:
logger.exception(f"用户配额自动重置任务执行失败: {e}")
try:
db.rollback()
except Exception:
pass
finally:
db.close()
async def _perform_cleanup(self) -> None: async def _perform_cleanup(self) -> None:
"""执行清理任务""" """执行清理任务"""
db = create_session() db = create_session()

View File

@@ -0,0 +1,166 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
import pytest
from src.services.system.maintenance_scheduler import MaintenanceScheduler
@pytest.mark.asyncio
async def test_user_quota_reset_disabled(monkeypatch):
scheduler = MaintenanceScheduler()
mock_db = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.create_session",
lambda: mock_db,
)
def fake_get_config(cls, db, key, default=None):
if key == "enable_user_quota_reset":
return False
return default
mock_set_config = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.get_config",
classmethod(fake_get_config),
)
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.set_config",
mock_set_config,
)
await scheduler._perform_user_quota_reset()
assert not mock_db.query.called
assert not mock_db.commit.called
assert not mock_set_config.called
@pytest.mark.asyncio
async def test_user_quota_reset_not_due_skips(monkeypatch):
scheduler = MaintenanceScheduler()
mock_db = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.create_session",
lambda: mock_db,
)
last_reset_at = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
def fake_get_config(cls, db, key, default=None):
if key == "enable_user_quota_reset":
return True
if key == "user_quota_reset_interval_days":
return 2
if key == "user_quota_last_reset_at":
return last_reset_at
return default
mock_set_config = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.get_config",
classmethod(fake_get_config),
)
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.set_config",
mock_set_config,
)
await scheduler._perform_user_quota_reset()
assert not mock_db.query.called
assert not mock_db.commit.called
assert not mock_set_config.called
@pytest.mark.asyncio
async def test_user_quota_reset_due_runs(monkeypatch):
scheduler = MaintenanceScheduler()
mock_db = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.create_session",
lambda: mock_db,
)
last_reset_at = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
def fake_get_config(cls, db, key, default=None):
if key == "enable_user_quota_reset":
return True
if key == "user_quota_reset_interval_days":
return 2
if key == "user_quota_last_reset_at":
return last_reset_at
return default
mock_set_config = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.get_config",
classmethod(fake_get_config),
)
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.set_config",
mock_set_config,
)
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.update.return_value = 7
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
await scheduler._perform_user_quota_reset()
mock_db.query.assert_called_once()
mock_filter.update.assert_called_once()
_, update_kwargs = mock_filter.update.call_args
assert update_kwargs["synchronize_session"] is False
mock_db.commit.assert_called_once()
mock_set_config.assert_called_once()
@pytest.mark.asyncio
async def test_user_quota_reset_invalid_interval_defaults_to_1(monkeypatch):
scheduler = MaintenanceScheduler()
mock_db = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.create_session",
lambda: mock_db,
)
def fake_get_config(cls, db, key, default=None):
if key == "enable_user_quota_reset":
return True
if key == "user_quota_reset_interval_days":
return "abc"
if key == "user_quota_last_reset_at":
return None
return default
mock_set_config = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.get_config",
classmethod(fake_get_config),
)
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.set_config",
mock_set_config,
)
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.update.return_value = 1
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
await scheduler._perform_user_quota_reset()
mock_db.commit.assert_called_once()
mock_set_config.assert_called_once()