feat(admin): 余额脚本驱动的余额/配额刷新与管理端体验修复

- 明确刷新语义:仅脚本启用且已配置时触发远程查询;未配置时前端禁用并提示\n- 新增余额脚本安全开关 BALANCE_SCRIPT_ENABLED(默认开启),脚本测试接口受控\n- Redis 增加单账户脚本配置存取,响应透出 scriptEnabled/scriptConfigured 供 UI 判定\n- accountBalanceService:本地统计汇总改用 SCAN+pipeline,避免 KEYS;仅缓存远程成功结果,避免失败/降级覆盖有效缓存\n- 管理端体验:刷新按钮按配置状态灰置;脚本弹窗内容可滚动、底部操作栏固定,并 append-to-body 使弹窗跟随当前视窗
This commit is contained in:
atoz03
2025-12-14 13:43:02 +08:00
parent 26ca696b91
commit f6f4b5cfec
11 changed files with 358 additions and 167 deletions

View File

@@ -11,6 +11,16 @@ const accountBalanceServiceModule = require('../src/services/accountBalanceServi
const { AccountBalanceService } = accountBalanceServiceModule
describe('AccountBalanceService', () => {
const originalBalanceScriptEnabled = process.env.BALANCE_SCRIPT_ENABLED
afterEach(() => {
if (originalBalanceScriptEnabled === undefined) {
delete process.env.BALANCE_SCRIPT_ENABLED
} else {
process.env.BALANCE_SCRIPT_ENABLED = originalBalanceScriptEnabled
}
})
const mockLogger = {
debug: jest.fn(),
info: jest.fn(),
@@ -24,6 +34,7 @@ describe('AccountBalanceService', () => {
getAccountBalance: jest.fn().mockResolvedValue(null),
setAccountBalance: jest.fn().mockResolvedValue(undefined),
deleteAccountBalance: jest.fn().mockResolvedValue(undefined),
getBalanceScriptConfig: jest.fn().mockResolvedValue(null),
getAccountUsageStats: jest.fn().mockResolvedValue({
total: { requests: 10 },
daily: { requests: 2, cost: 20 },
@@ -87,7 +98,7 @@ describe('AccountBalanceService', () => {
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
})
it('should cache provider errors and fallback to local when queryApi=true', async () => {
it('should not cache provider errors and fallback to local when queryApi=true', async () => {
const mockRedis = buildMockRedis()
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
@@ -106,12 +117,78 @@ describe('AccountBalanceService', () => {
useCache: false
})
expect(mockRedis.setAccountBalance).toHaveBeenCalled()
expect(mockRedis.setAccountBalance).not.toHaveBeenCalled()
expect(result.data.source).toBe('local')
expect(result.data.status).toBe('error')
expect(result.data.error).toBe('boom')
})
it('should ignore script config when balance script is disabled', async () => {
process.env.BALANCE_SCRIPT_ENABLED = 'false'
const mockRedis = buildMockRedis()
mockRedis.getBalanceScriptConfig.mockResolvedValue({
scriptBody: '({ request: { url: \"http://example.com\" }, extractor: function(){ return {} } })'
})
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
service._computeTotalCost = jest.fn().mockResolvedValue(0)
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 1, currency: 'USD' }) }
service.registerProvider('openai', provider)
const scriptSpy = jest.spyOn(service, '_getBalanceFromScript')
const account = { id: 'acct-script-off', name: 'S' }
const result = await service._getAccountBalanceForAccount(account, 'openai', {
queryApi: true,
useCache: false
})
expect(provider.queryBalance).toHaveBeenCalled()
expect(scriptSpy).not.toHaveBeenCalled()
expect(result.data.source).toBe('api')
})
it('should prefer script when configured and enabled', async () => {
process.env.BALANCE_SCRIPT_ENABLED = 'true'
const mockRedis = buildMockRedis()
mockRedis.getBalanceScriptConfig.mockResolvedValue({
scriptBody: '({ request: { url: \"http://example.com\" }, extractor: function(){ return {} } })'
})
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
service._computeMonthlyCost = jest.fn().mockResolvedValue(0)
service._computeTotalCost = jest.fn().mockResolvedValue(0)
const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 2, currency: 'USD' }) }
service.registerProvider('openai', provider)
jest.spyOn(service, '_getBalanceFromScript').mockResolvedValue({
status: 'success',
balance: 3,
currency: 'USD',
quota: null,
queryMethod: 'script',
rawData: { ok: true },
lastRefreshAt: '2025-01-01T00:00:00Z',
errorMessage: ''
})
const account = { id: 'acct-script-on', name: 'T' }
const result = await service._getAccountBalanceForAccount(account, 'openai', {
queryApi: true,
useCache: false
})
expect(provider.queryBalance).not.toHaveBeenCalled()
expect(result.data.source).toBe('api')
expect(result.data.balance.amount).toBeCloseTo(3, 6)
expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z')
})
it('should count low balance once per account in summary', async () => {
const mockRedis = buildMockRedis()
const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger })
@@ -139,4 +216,3 @@ describe('AccountBalanceService', () => {
expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1)
})
})