diff --git a/src/modules/ai-runtime/user-ai-quota.service.ts b/src/modules/ai-runtime/user-ai-quota.service.ts index f2287df..acaf25c 100644 --- a/src/modules/ai-runtime/user-ai-quota.service.ts +++ b/src/modules/ai-runtime/user-ai-quota.service.ts @@ -11,8 +11,8 @@ export class UserAiQuotaService { return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); } - /** Check if user can create a new AI job, throw if over limit */ - async checkAndReserve(userId: string, apiKeyMode: string): Promise { + /** Read-only check: throws if user is over daily job or token limit. Actual reservation (upsert) is done by incrementJobCount. */ + async checkQuota(userId: string, apiKeyMode: string): Promise { const settings = await this.prisma.userAiSettings.findUnique({ where: { userId } }); const maxJobs = settings?.maxDailyAiJobs ?? 20; const maxTokens = settings?.maxDailyTokenBudget ?? 100_000; diff --git a/src/modules/ai-runtime/user-ai.service.spec.ts b/src/modules/ai-runtime/user-ai.service.spec.ts index effb3eb..25db00a 100644 --- a/src/modules/ai-runtime/user-ai.service.spec.ts +++ b/src/modules/ai-runtime/user-ai.service.spec.ts @@ -29,7 +29,7 @@ describe('UserAiService.createAnalysisJob', () => { }; snapshotBuilder = { buildSnapshot: jest.fn().mockResolvedValue(mockSnapshot) }; 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) }; 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); - expect(quota.checkAndReserve).toHaveBeenCalledWith('u1', 'platform_key'); + expect(quota.checkQuota).toHaveBeenCalledWith('u1', 'platform_key'); expect(budget.checkPlatformBudget).toHaveBeenCalledWith('deepseek', 'deepseek-chat'); expect(quota.incrementJobCount).toHaveBeenCalledWith('u1', 'platform_key'); }); diff --git a/src/modules/ai-runtime/user-ai.service.ts b/src/modules/ai-runtime/user-ai.service.ts index d101a99..afc8f61 100644 --- a/src/modules/ai-runtime/user-ai.service.ts +++ b/src/modules/ai-runtime/user-ai.service.ts @@ -230,6 +230,7 @@ export class UserAiService { } // 4. Resolve apiKeyMode / credentialId + // NOTE: apiKeyMode may be reassigned in step 6 fallback — steps 7–11 consume the final values let apiKeyMode = dto.apiKeyMode ?? settings.apiKeyMode; let credentialId: string | undefined = dto.credentialId ?? settings.defaultCredentialId ?? undefined; if (apiKeyMode === 'user_deepseek_key' && !credentialId) { @@ -244,8 +245,8 @@ export class UserAiService { } } - // 5. Quota check - await this.quota.checkAndReserve(userId, apiKeyMode); + // 5. Quota check (read-only fast-fail; actual reservation at step 11) + await this.quota.checkQuota(userId, apiKeyMode); // 6. Platform budget check (platform_key only) — with fallback to user key if (apiKeyMode === 'platform_key') { @@ -253,7 +254,7 @@ export class UserAiService { await this.budget.checkPlatformBudget('deepseek', 'deepseek-chat'); } catch (err: any) { 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'; credentialId = settings.defaultCredentialId; } else {