fix: clarify quota check is read-only, prevent refactoring pitfalls
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 46s
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:
parent
c88af39673
commit
7aea03f6e0
@ -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;
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 7–11 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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user