feat(proxies): 代理有效期与失败回退

- schema/迁移: 代理有效期、提醒天数、失败回退配置 + 账号 fallback 来源字段
- service/repo/DTO/handler: CRUD 透传新字段 + 校验
- fallback 目标解析纯函数(链式解析 + 环检测 + 兜底)
- SweepExpiredProxies 到期改投账号 + outbox 失效
- ProxyExpiryService 后台到期扫描任务 + wire 注册
- 账号侧手动回切原代理 + fallback 来源徽章/按钮
- 前端: 创建/编辑表单、列表到期徽章、类型/API/i18n
- ops 告警: proxy_expired_count / proxy_expiring_soon_count 指标
- 导入导出携带有效期/回退字段(备用按 name 映射)
- 补全测试 stub + 集成测试 + review 问题修复
This commit is contained in:
DaydreamCoding
2026-06-08 00:01:30 +08:00
parent f20e6bf769
commit af19d44327
61 changed files with 3430 additions and 281 deletions
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { daysUntil, proxyExpiryBadgeClass, proxyExpiryLabelKey } from '../proxyExpiry'
// 固定「现在」,按天数构造确定输入:isoInDays(n) 距今正好 n 天
const NOW = new Date('2026-06-02T00:00:00Z')
const isoInDays = (n: number): string => new Date(NOW.getTime() + n * 86400000).toISOString()
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(() => {
vi.useRealTimers()
})
describe('daysUntil', () => {
it('返回距今整天数', () => {
expect(daysUntil(isoInDays(10))).toBe(10)
expect(daysUntil(isoInDays(-3))).toBe(-3)
})
})
describe('proxyExpiryBadgeClass', () => {
it('status=expired → danger', () => {
expect(proxyExpiryBadgeClass(isoInDays(30), 'expired')).toBe('badge badge-danger')
})
it('≤3 天 → danger(含边界 3)', () => {
expect(proxyExpiryBadgeClass(isoInDays(2), 'active')).toBe('badge badge-danger')
expect(proxyExpiryBadgeClass(isoInDays(3), 'active')).toBe('badge badge-danger')
})
it('4–7 天 → warning(含边界 7)', () => {
expect(proxyExpiryBadgeClass(isoInDays(5), 'active')).toBe('badge badge-warning')
expect(proxyExpiryBadgeClass(isoInDays(7), 'active')).toBe('badge badge-warning')
})
it('>7 天 → gray', () => {
expect(proxyExpiryBadgeClass(isoInDays(30), 'active')).toBe('text-gray-500')
})
})
describe('proxyExpiryLabelKey', () => {
it('status=expired → expired key', () => {
expect(proxyExpiryLabelKey(isoInDays(30), 'expired')).toEqual({ key: 'admin.proxies.expired' })
})
it('已逾期(d<0) → overdueDays', () => {
expect(proxyExpiryLabelKey(isoInDays(-3), 'active')).toEqual({
key: 'admin.proxies.overdueDays',
params: { days: 3 },
})
})
it('≤7 天 → expiringInDays', () => {
expect(proxyExpiryLabelKey(isoInDays(5), 'active')).toEqual({
key: 'admin.proxies.expiringInDays',
params: { days: 5 },
})
})
it('>7 天 → remainingDays', () => {
expect(proxyExpiryLabelKey(isoInDays(30), 'active')).toEqual({
key: 'admin.proxies.remainingDays',
params: { days: 30 },
})
})
})
+29
View File
@@ -0,0 +1,29 @@
// 代理有效期展示逻辑(ProxiesView 与 AccountsView 共用)。
// 到期紧迫度固定两档:剩余 ≤3 天红、≤7 天黄(不读 per-proxy expiry_warn_days)。
export const EXPIRY_WARN_DAYS = 7
export const EXPIRY_DANGER_DAYS = 3
// 距今整天数(向上取整)。
export const daysUntil = (iso: string): number =>
Math.ceil((new Date(iso).getTime() - Date.now()) / 86400000)
// 倒计时徽章的 CSS class(纯函数,无 i18n 依赖)。
export function proxyExpiryBadgeClass(expiresAt: string | null, status?: string): string {
if (status === 'expired') return 'badge badge-danger'
const d = expiresAt ? daysUntil(expiresAt) : Infinity
if (d <= EXPIRY_DANGER_DAYS) return 'badge badge-danger'
if (d <= EXPIRY_WARN_DAYS) return 'badge badge-warning'
return 'text-gray-500'
}
// 倒计时文案的 i18n key + 参数(返回 key 而非已翻译文本,便于单测且不耦合 i18n)。
export function proxyExpiryLabelKey(
expiresAt: string | null,
status?: string,
): { key: string; params?: { days: number } } {
if (status === 'expired') return { key: 'admin.proxies.expired' }
const d = expiresAt ? daysUntil(expiresAt) : Infinity
if (d < 0) return { key: 'admin.proxies.overdueDays', params: { days: Math.abs(d) } }
if (d <= EXPIRY_WARN_DAYS) return { key: 'admin.proxies.expiringInDays', params: { days: d } }
return { key: 'admin.proxies.remainingDays', params: { days: d } }
}