fix: clarify quota check is read-only, prevent refactoring pitfalls
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 46s

- Rename checkAndReserve→checkQuota (method only reads, does not reserve)
- Add doc comment: incrementJobCount is the actual quota reservation
- Update user-ai.service.spec.ts references
- Add comments for apiKeyMode/credentialId reassignment in budget fallback

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-18 11:36:01 +08:00
parent c88af39673
commit 7aea03f6e0
3 changed files with 8 additions and 7 deletions

View File

@ -11,8 +11,8 @@ export class UserAiQuotaService {
return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
} }
/** Check if user can create a new AI job, throw if over limit */ /** Read-only check: throws if user is over daily job or token limit. Actual reservation (upsert) is done by incrementJobCount. */
async checkAndReserve(userId: string, apiKeyMode: string): Promise<void> { async checkQuota(userId: string, apiKeyMode: string): Promise<void> {
const settings = await this.prisma.userAiSettings.findUnique({ where: { userId } }); const settings = await this.prisma.userAiSettings.findUnique({ where: { userId } });
const maxJobs = settings?.maxDailyAiJobs ?? 20; const maxJobs = settings?.maxDailyAiJobs ?? 20;
const maxTokens = settings?.maxDailyTokenBudget ?? 100_000; const maxTokens = settings?.maxDailyTokenBudget ?? 100_000;

View File

@ -29,7 +29,7 @@ describe('UserAiService.createAnalysisJob', () => {
}; };
snapshotBuilder = { buildSnapshot: jest.fn().mockResolvedValue(mockSnapshot) }; snapshotBuilder = { buildSnapshot: jest.fn().mockResolvedValue(mockSnapshot) };
priorityRules = { computeJobPriority: jest.fn().mockReturnValue(50) }; priorityRules = { computeJobPriority: jest.fn().mockReturnValue(50) };
quota = { checkAndReserve: jest.fn().mockResolvedValue(undefined), incrementJobCount: jest.fn().mockResolvedValue(undefined) }; quota = { checkQuota: jest.fn().mockResolvedValue(undefined), incrementJobCount: jest.fn().mockResolvedValue(undefined) };
budget = { checkPlatformBudget: jest.fn().mockResolvedValue(undefined) }; budget = { checkPlatformBudget: jest.fn().mockResolvedValue(undefined) };
const crypto = { encrypt: jest.fn(), decrypt: jest.fn(), hash: jest.fn(), mask: jest.fn() } as any; const crypto = { encrypt: jest.fn(), decrypt: jest.fn(), hash: jest.fn(), mask: jest.fn() } as any;
@ -132,7 +132,7 @@ describe('UserAiService.createAnalysisJob', () => {
await service.createAnalysisJob('u1', validDto); await service.createAnalysisJob('u1', validDto);
expect(quota.checkAndReserve).toHaveBeenCalledWith('u1', 'platform_key'); expect(quota.checkQuota).toHaveBeenCalledWith('u1', 'platform_key');
expect(budget.checkPlatformBudget).toHaveBeenCalledWith('deepseek', 'deepseek-chat'); expect(budget.checkPlatformBudget).toHaveBeenCalledWith('deepseek', 'deepseek-chat');
expect(quota.incrementJobCount).toHaveBeenCalledWith('u1', 'platform_key'); expect(quota.incrementJobCount).toHaveBeenCalledWith('u1', 'platform_key');
}); });

View File

@ -230,6 +230,7 @@ export class UserAiService {
} }
// 4. Resolve apiKeyMode / credentialId // 4. Resolve apiKeyMode / credentialId
// NOTE: apiKeyMode may be reassigned in step 6 fallback — steps 711 consume the final values
let apiKeyMode = dto.apiKeyMode ?? settings.apiKeyMode; let apiKeyMode = dto.apiKeyMode ?? settings.apiKeyMode;
let credentialId: string | undefined = dto.credentialId ?? settings.defaultCredentialId ?? undefined; let credentialId: string | undefined = dto.credentialId ?? settings.defaultCredentialId ?? undefined;
if (apiKeyMode === 'user_deepseek_key' && !credentialId) { if (apiKeyMode === 'user_deepseek_key' && !credentialId) {
@ -244,8 +245,8 @@ export class UserAiService {
} }
} }
// 5. Quota check // 5. Quota check (read-only fast-fail; actual reservation at step 11)
await this.quota.checkAndReserve(userId, apiKeyMode); await this.quota.checkQuota(userId, apiKeyMode);
// 6. Platform budget check (platform_key only) — with fallback to user key // 6. Platform budget check (platform_key only) — with fallback to user key
if (apiKeyMode === 'platform_key') { if (apiKeyMode === 'platform_key') {
@ -253,7 +254,7 @@ export class UserAiService {
await this.budget.checkPlatformBudget('deepseek', 'deepseek-chat'); await this.budget.checkPlatformBudget('deepseek', 'deepseek-chat');
} catch (err: any) { } catch (err: any) {
if (err?.response?.errorCode === 'PLATFORM_CIRCUIT_OPEN' && settings.fallbackToPlatformKey && settings.defaultCredentialId) { if (err?.response?.errorCode === 'PLATFORM_CIRCUIT_OPEN' && settings.fallbackToPlatformKey && settings.defaultCredentialId) {
// Fallback: switch to user key mode // Fallback: apiKeyMode + credentialId reassigned — downstream steps use updated values
apiKeyMode = 'user_deepseek_key'; apiKeyMode = 'user_deepseek_key';
credentialId = settings.defaultCredentialId; credentialId = settings.defaultCredentialId;
} else { } else {