fix(billing): align zero and unlimited quota semantics (#41927)

This commit is contained in:
yyh 2026-09-08 01:14:06 +00:00 committed by GitHub
parent 8980506597
commit 4f463b1c46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 107 additions and 23 deletions

View File

@ -536,7 +536,7 @@ class AppAnnotationService:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
features = FeatureService.get_features(current_tenant_id, exclude_vector_space=True)
annotation_quota_limit = features.annotation_quota_limit
if annotation_quota_limit.limit < len(result) + annotation_quota_limit.size:
if 0 < annotation_quota_limit.limit < len(result) + annotation_quota_limit.size:
raise ValueError("The number of annotations exceeds the limit of your subscription.")
# async job
job_id = str(uuid.uuid4())

View File

@ -719,6 +719,29 @@ class TestAppAnnotationServiceBatchImport:
"uuid-3", [{"question": "q1", "answer": "a1"}], app.id, TENANT_ID, current_user.id
)
@pytest.mark.parametrize("limit", [0, 2], ids=["unlimited", "exactly-at-limit"])
@config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
def test_import_with_available_quota_enqueues_job(
self, sqlite_session: Session, current_user: Account, limit: int
) -> None:
app = _persist_app(sqlite_session)
features = SimpleNamespace(annotation_quota_limit=SimpleNamespace(limit=limit, size=1))
with (
patch.object(annotation_service_module.FeatureService, "get_features", return_value=features),
patch.object(annotation_service_module, "batch_import_annotations_task") as task,
patch.object(annotation_service_module, "redis_client"),
config_overrides_context(ANNOTATION_IMPORT_MAX_RECORDS=5, ANNOTATION_IMPORT_MIN_RECORDS=1),
):
result = AppAnnotationService.batch_import_app_annotations(
app.id, _file(b"question,answer\nq,a\n"), sqlite_session
)
assert result["job_status"] == "waiting"
assert result["record_count"] == 1
task.delay.assert_called_once_with(
result["job_id"], [{"question": "q", "answer": "a"}], app.id, TENANT_ID, current_user.id
)
@config_overrides_context(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY)
def test_unexpected_error_cleans_active_job(
self, sqlite_session: Session, current_user: Account, caplog: pytest.LogCaptureFixture

View File

@ -1,10 +1,13 @@
import { act, render, screen, within } from '@testing-library/react'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { consoleQuery } from '@/service/client'
import {
createConsoleQueryClient,
createConsoleQueryWrapper,
seedFeatures,
} from '@/test/console/query-data'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import AnnotationUsage from '../annotation-full/usage'
import Billing from '../billing-page'
@ -75,3 +78,73 @@ it('renders annotation usage only from returned data and preserves zero as an un
expect(within(annotation).getByText('4')).toBeInTheDocument()
expect(within(annotation).getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
})
it.each([
{ limit: 0, usage: 0, percent: 100 },
{ limit: -1, usage: 25, percent: 0 },
{ limit: 100, usage: 25, percent: 25 },
{ limit: 100, usage: 100, percent: 100 },
])(
'renders event and API quotas with limit $limit and usage $usage',
async ({ limit, usage, percent }) => {
const { queryClient, wrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: 'CLOUD' },
features: {
trigger_event: { limit, usage },
api_rate_limit: { limit, usage },
apps: { size: 4, limit: 0 },
},
})
queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryKey(), {
size: 256,
limit: 900,
usage_unknown: false,
})
render(<Billing />, { wrapper })
for (const name of ['billing.usagePage.triggerEvents', 'billing.plansCommon.apiRateLimit']) {
const quota = within(await screen.findByRole('group', { name }))
expect(quota.getByTestId('billing-quota-value')).toHaveTextContent(
`${usage}/${limit === -1 ? 'billing.plansCommon.unlimited' : limit}`,
)
expect(quota.getByRole('meter', { name })).toHaveAttribute('aria-valuenow', String(percent))
if (limit !== -1)
expect(quota.queryByText('billing.plansCommon.unlimited')).not.toBeInTheDocument()
}
const apps = within(screen.getByRole('group', { name: 'billing.usagePage.buildApps' }))
expect(apps.getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
},
)
it('shows a dismissible limit dialog when the workspace receives a zero event quota', async () => {
localStorage.clear()
const user = userEvent.setup()
const { queryClient, wrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: 'CLOUD' },
currentWorkspace: { id: 'workspace-zero-quota' },
})
const { wrapper: NuqsWrapper } = createNuqsTestWrapper()
render(
<NuqsWrapper>
<ModalContextProvider>
<span>Workspace</span>
</ModalContextProvider>
</NuqsWrapper>,
{ wrapper },
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await act(async () => {
seedFeatures(queryClient, {
billing: { subscription: { plan: 'professional' } },
trigger_event: { limit: 0, usage: 0, reset_date: -1 },
})
})
const dialog = await screen.findByRole('dialog', { name: 'billing.triggerLimitModal.title' })
expect(within(dialog).getByTestId('billing-quota-value')).toHaveTextContent('0/0')
await user.click(
within(dialog).getByRole('button', { name: 'billing.triggerLimitModal.dismiss' }),
)
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})

View File

@ -19,7 +19,7 @@ import { NUM_INFINITE } from '../config'
import { useEducationDiscount } from '../hooks/use-education-discount'
import UpgradeBtn from '../upgrade-btn'
import VectorSpaceInfo from '../usage-info/vector-space-info'
import { getResetInDaysFromDate, parseLimit, parseRateLimit } from '../utils'
import { getResetInDaysFromDate, parseLimit } from '../utils'
import { Professional, Sandbox, Team } from './assets'
type Props = Readonly<{
@ -49,8 +49,8 @@ const PlanComp: FC<Props> = ({ loc }) => {
)
const { isAboutToExpire = false, isEducationAccount = false } = educationStatus ?? {}
const type = features.billing.subscription.plan
const triggerEventsLimit = parseRateLimit(features.trigger_event.limit)
const apiRateLimit = parseRateLimit(features.api_rate_limit.limit)
const triggerEventsLimit = features.trigger_event.limit
const apiRateLimit = features.api_rate_limit.limit
const apiRateLimitReset = getResetInDaysFromDate(features.api_rate_limit.reset_date)
const triggerEventsResetInDays =
type === 'professional' && triggerEventsLimit !== NUM_INFINITE

View File

@ -52,11 +52,10 @@ const UsageInfo: FC<Props> = ({
const isBelowThreshold = !usageUnknown && storageMode && usage < storageThreshold
const isSandboxFull = !usageUnknown && storageMode && isSandboxPlan && usage >= storageThreshold
// Single source of truth: sandbox full is visually clamped to 100%; all other
// determinate cases show the real percent capped at 100. Tone derives from
// this, so we never need a separate tone override.
// Zero count quotas have no remaining capacity; storage keeps its separate limit convention.
const isZeroQuota = !storageMode && total === 0
const rawPercent = total > 0 ? (usage / total) * 100 : 0
const effectivePercent = isSandboxFull ? 100 : Math.min(rawPercent, 100)
const effectivePercent = isSandboxFull || isZeroQuota ? 100 : Math.min(rawPercent, 100)
const tone: MeterTone =
effectivePercent >= 100 ? 'error' : effectivePercent >= 80 ? 'warning' : 'neutral'

View File

@ -2,7 +2,6 @@ import {
getPlanVectorSpaceLimitMB,
getResetInDaysFromDate,
parseLimit,
parseRateLimit,
parseVectorSpaceToMB,
} from '../index'
@ -71,10 +70,5 @@ describe('billing utils', () => {
expect(parseLimit(0)).toBe(-1)
expect(parseLimit(10)).toBe(10)
})
it('preserves unlimited rate limits in either API representation', () => {
expect(parseRateLimit(0)).toBe(-1)
expect(parseRateLimit(-1)).toBe(-1)
expect(parseRateLimit(5000)).toBe(5000)
})
})
})

View File

@ -23,19 +23,14 @@ export const getPlanVectorSpaceLimitMB = (planType: CloudPlan): number => {
return parseVectorSpaceToMB(ALL_PLANS[planType].vectorSpace)
}
// The API uses 0 for unlimited count quotas.
// App, member, document upload, and annotation quotas use 0 for unlimited.
// Event and API quotas use -1 for unlimited and must preserve 0 as zero capacity.
export const parseLimit = (limit: number) => {
if (limit === 0) return NUM_INFINITE
return limit
}
export const parseRateLimit = (limit: number) => {
if (limit === 0 || limit === -1) return NUM_INFINITE
return limit
}
const normalizeResetDate = (resetDate: number) => {
if (resetDate <= 0) return null

View File

@ -48,7 +48,7 @@ export const useTriggerEventsLimitModal = (): UseTriggerEventsLimitModalResult =
currentWorkspaceId &&
quota &&
quota.plan !== 'team' &&
quota.limit > 0 &&
quota.limit >= 0 &&
quota.usage >= quota.limit
? `${TRIGGER_EVENTS_LOCALSTORAGE_PREFIX}-${currentWorkspaceId}-${quota.plan}-${quota.limit}-${cycleTag}`
: null