mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
- 新增 accountBalanceService 与多 Provider 适配(Claude/Claude Console/OpenAI Responses/通用) - Redis 增加余额查询结果与本地统计缓存读写 - 管理端新增 /admin/accounts/balance 相关接口与汇总接口,并在应用启动时注册 Provider - 后台前端新增余额组件与 Dashboard 余额/配额汇总、低余额/高使用提示 - 补充 accountBalanceService 单元测试
143 lines
5.0 KiB
JavaScript
143 lines
5.0 KiB
JavaScript
// Mock logger,避免测试输出污染控制台
|
||
jest.mock('../src/utils/logger', () => ({
|
||
debug: jest.fn(),
|
||
info: jest.fn(),
|
||
warn: jest.fn(),
|
||
error: jest.fn()
|
||
}))
|
||
|
||
const accountBalanceServiceModule = require('../src/services/accountBalanceService')
|
||
|
||
const { AccountBalanceService } = accountBalanceServiceModule
|
||
|
||
describe('AccountBalanceService', () => {
|
||
const mockLogger = {
|
||
debug: jest.fn(),
|
||
info: jest.fn(),
|
||
warn: jest.fn(),
|
||
error: jest.fn()
|
||
}
|
||
|
||
const buildMockRedis = () => ({
|
||
getLocalBalance: jest.fn().mockResolvedValue(null),
|
||
setLocalBalance: jest.fn().mockResolvedValue(undefined),
|
||
getAccountBalance: jest.fn().mockResolvedValue(null),
|
||
setAccountBalance: jest.fn().mockResolvedValue(undefined),
|
||
deleteAccountBalance: jest.fn().mockResolvedValue(undefined),
|
||
getAccountUsageStats: jest.fn().mockResolvedValue({
|
||
total: { requests: 10 },
|
||
daily: { requests: 2, cost: 20 },
|
||
monthly: { requests: 5 }
|
||
}),
|
||
getDateInTimezone: (date) => new Date(date.getTime() + 8 * 3600 * 1000)
|
||
})
|
||
|
||
it('should normalize platform aliases', () => {
|
||
const service = new AccountBalanceService({ redis: buildMockRedis(), logger: mockLogger })
|
||
expect(service.normalizePlatform('claude-official')).toBe('claude')
|
||
expect(service.normalizePlatform('azure-openai')).toBe('azure_openai')
|
||
expect(service.normalizePlatform('gemini-api')).toBe('gemini-api')
|
||
})
|
||
|
||
it('should build local quota/balance from dailyQuota and local dailyCost', async () => {
|
||
const mockRedis = buildMockRedis()
|
||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||
|
||
service._computeMonthlyCost = jest.fn().mockResolvedValue(30)
|
||
service._computeTotalCost = jest.fn().mockResolvedValue(123.45)
|
||
|
||
const account = { id: 'acct-1', name: 'A', dailyQuota: '100', quotaResetTime: '00:00' }
|
||
const result = await service._getAccountBalanceForAccount(account, 'claude-console', {
|
||
queryApi: false,
|
||
useCache: true
|
||
})
|
||
|
||
expect(result.success).toBe(true)
|
||
expect(result.data.source).toBe('local')
|
||
expect(result.data.balance.amount).toBeCloseTo(80, 6)
|
||
expect(result.data.quota.percentage).toBeCloseTo(20, 6)
|
||
expect(result.data.statistics.totalCost).toBeCloseTo(123.45, 6)
|
||
expect(mockRedis.setLocalBalance).toHaveBeenCalled()
|
||
})
|
||
|
||
it('should use cached balance when account has no dailyQuota', async () => {
|
||
const mockRedis = buildMockRedis()
|
||
mockRedis.getAccountBalance.mockResolvedValue({
|
||
status: 'success',
|
||
balance: 12.34,
|
||
currency: 'USD',
|
||
quota: null,
|
||
errorMessage: '',
|
||
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||
ttlSeconds: 120
|
||
})
|
||
|
||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||
|
||
const account = { id: 'acct-2', name: 'B' }
|
||
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||
queryApi: false,
|
||
useCache: true
|
||
})
|
||
|
||
expect(result.data.source).toBe('cache')
|
||
expect(result.data.balance.amount).toBeCloseTo(12.34, 6)
|
||
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
|
||
})
|
||
|
||
it('should cache provider errors and fallback to local when queryApi=true', async () => {
|
||
const mockRedis = buildMockRedis()
|
||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||
|
||
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
|
||
service._computeTotalCost = jest.fn().mockResolvedValue(0)
|
||
|
||
service.registerProvider('openai', {
|
||
queryBalance: () => {
|
||
throw new Error('boom')
|
||
}
|
||
})
|
||
|
||
const account = { id: 'acct-3', name: 'C' }
|
||
const result = await service._getAccountBalanceForAccount(account, 'openai', {
|
||
queryApi: true,
|
||
useCache: false
|
||
})
|
||
|
||
expect(mockRedis.setAccountBalance).toHaveBeenCalled()
|
||
expect(result.data.source).toBe('local')
|
||
expect(result.data.status).toBe('error')
|
||
expect(result.data.error).toBe('boom')
|
||
})
|
||
|
||
it('should count low balance once per account in summary', async () => {
|
||
const mockRedis = buildMockRedis()
|
||
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
|
||
|
||
service.getSupportedPlatforms = () => ['claude-console']
|
||
service.getAllAccountsByPlatform = async () => [{ id: 'acct-4', name: 'D' }]
|
||
service._getAccountBalanceForAccount = async () => ({
|
||
success: true,
|
||
data: {
|
||
accountId: 'acct-4',
|
||
platform: 'claude-console',
|
||
balance: { amount: 5, currency: 'USD', formattedAmount: '$5.00' },
|
||
quota: { percentage: 95 },
|
||
statistics: { totalCost: 1 },
|
||
source: 'local',
|
||
lastRefreshAt: '2025-01-01T00:00:00Z',
|
||
cacheExpiresAt: null,
|
||
status: 'success',
|
||
error: null
|
||
}
|
||
})
|
||
|
||
const summary = await service.getBalanceSummary()
|
||
expect(summary.lowBalanceCount).toBe(1)
|
||
expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1)
|
||
})
|
||
})
|
||
|