chore: backend feature api exclude_vector_space

This commit is contained in:
hjlarry
2026-05-26 09:30:17 +08:00
parent 3cabe9058b
commit 36a51dca8b
24 changed files with 154 additions and 39 deletions

View File

@ -76,7 +76,55 @@ register_response_schema_models(console_ns, SimpleResultDataResponse, Verificati
def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool:
if role != TenantAccountRole.DATASET_OPERATOR:
return True
return FeatureService.get_features(tenant_id=tenant_id).dataset_operator_enabled
return FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True).dataset_operator_enabled
def _normalize_invitee_emails(emails: list[str]) -> list[str]:
return list(dict.fromkeys(email.lower() for email in emails))
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> int:
new_member_count = 0
for email in emails:
account = AccountService.get_account_by_email_with_case_fallback(email)
if not account:
new_member_count += 1
continue
exists = db.session.scalar(
select(TenantAccountJoin.id)
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == account.id)
.limit(1)
)
if not exists:
new_member_count += 1
return new_member_count
def _count_current_members(tenant_id: str) -> int:
return (
db.session.scalar(select(func.count(TenantAccountJoin.id)).where(TenantAccountJoin.tenant_id == tenant_id)) or 0
)
def _check_member_invite_limits(tenant_id: str, new_member_count: int) -> None:
if new_member_count <= 0:
return
features = FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True)
if dify_config.ENTERPRISE_ENABLED:
workspace_members = features.workspace_members
if workspace_members.enabled is True and not workspace_members.is_available(new_member_count):
raise WorkspaceMembersLimitExceeded()
return
if dify_config.BILLING_ENABLED and features.billing.enabled is True:
members = features.members
current_member_count = _count_current_members(tenant_id)
if 0 < members.limit < current_member_count + new_member_count:
raise WorkspaceMembersLimitExceeded()
@console_ns.route("/workspaces/current/members")

View File

@ -166,10 +166,10 @@ class TenantListApi(Resource):
if tenant_plan:
plan = tenant_plan["plan"] or CloudPlan.SANDBOX
else:
features = FeatureService.get_features(tenant.id)
features = FeatureService.get_features(tenant.id, exclude_vector_space=True)
plan = features.billing.subscription.plan or CloudPlan.SANDBOX
elif not is_enterprise_only:
features = FeatureService.get_features(tenant.id)
features = FeatureService.get_features(tenant.id, exclude_vector_space=True)
plan = features.billing.subscription.plan or CloudPlan.SANDBOX
# Create a dictionary with tenant attributes

View File

@ -96,21 +96,26 @@ def cloud_edition_billing_resource_check[**P, R](resource: str) -> Callable[[Cal
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
_, current_tenant_id = current_account_with_tenant()
features = FeatureService.get_features(current_tenant_id)
features = FeatureService.get_features(
current_tenant_id,
exclude_vector_space=resource != "vector_space",
)
if features.billing.enabled:
members = features.members
apps = features.apps
vector_space = features.vector_space
documents_upload_quota = features.documents_upload_quota
annotation_quota_limit = features.annotation_quota_limit
if resource == "members" and 0 < members.limit <= members.size:
abort(403, "The number of members has reached the limit of your subscription.")
elif resource == "apps" and 0 < apps.limit <= apps.size:
abort(403, "The number of apps has reached the limit of your subscription.")
elif resource == "vector_space" and 0 < vector_space.limit <= vector_space.size:
abort(
403, "The capacity of the knowledge storage space has reached the limit of your subscription."
)
elif resource == "vector_space":
vector_space = features.vector_space
if 0 < vector_space.limit <= vector_space.size:
abort(
403,
"The capacity of the knowledge storage space has reached the limit of your subscription.",
)
elif resource == "documents" and 0 < documents_upload_quota.limit <= documents_upload_quota.size:
# The api of file upload is used in the multiple places,
# so we need to check the source of the request from datasets
@ -140,7 +145,7 @@ def cloud_edition_billing_knowledge_limit_check[**P, R](
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
_, current_tenant_id = current_account_with_tenant()
features = FeatureService.get_features(current_tenant_id)
features = FeatureService.get_features(current_tenant_id, exclude_vector_space=True)
if features.billing.enabled:
if resource == "add_segment":
if features.billing.subscription.plan == CloudPlan.SANDBOX:
@ -295,7 +300,7 @@ def knowledge_pipeline_publish_enabled[**P, R](view: Callable[P, R]) -> Callable
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
_, current_tenant_id = current_account_with_tenant()
features = FeatureService.get_features(current_tenant_id)
features = FeatureService.get_features(current_tenant_id, exclude_vector_space=True)
if features.knowledge_pipeline.publish_enabled:
return view(*args, **kwargs)
abort(403)

View File

@ -140,20 +140,24 @@ def cloud_edition_billing_resource_check[**P, R](
def interceptor(view: Callable[P, R]):
def decorated(*args: P.args, **kwargs: P.kwargs):
api_token = validate_and_get_api_token(api_token_type)
features = FeatureService.get_features(api_token.tenant_id)
features = FeatureService.get_features(
api_token.tenant_id,
exclude_vector_space=resource != "vector_space",
)
if features.billing.enabled:
members = features.members
apps = features.apps
vector_space = features.vector_space
documents_upload_quota = features.documents_upload_quota
if resource == "members" and 0 < members.limit <= members.size:
raise Forbidden("The number of members has reached the limit of your subscription.")
elif resource == "apps" and 0 < apps.limit <= apps.size:
raise Forbidden("The number of apps has reached the limit of your subscription.")
elif resource == "vector_space" and 0 < vector_space.limit <= vector_space.size:
raise Forbidden("The capacity of the vector space has reached the limit of your subscription.")
elif resource == "vector_space":
vector_space = features.vector_space
if 0 < vector_space.limit <= vector_space.size:
raise Forbidden("The capacity of the vector space has reached the limit of your subscription.")
elif resource == "documents" and 0 < documents_upload_quota.limit <= documents_upload_quota.size:
raise Forbidden("The number of documents has reached the limit of your subscription.")
else:
@ -174,7 +178,7 @@ def cloud_edition_billing_knowledge_limit_check[**P, R](
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
api_token = validate_and_get_api_token(api_token_type)
features = FeatureService.get_features(api_token.tenant_id)
features = FeatureService.get_features(api_token.tenant_id, exclude_vector_space=True)
if features.billing.enabled:
if resource == "add_segment":
if features.billing.subscription.plan == CloudPlan.SANDBOX:

View File

@ -81,7 +81,7 @@ class AppSiteApi(WebApiResource):
if app_model.tenant.status == TenantStatus.ARCHIVE:
raise Forbidden()
can_replace_logo = FeatureService.get_features(app_model.tenant_id).can_replace_logo
can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo
return AppSiteInfo(app_model.tenant, app_model, site, end_user.id, can_replace_logo)
@ -119,6 +119,6 @@ def serialize_site(site: Site) -> dict[str, Any]:
def serialize_app_site_payload(app_model: App, site: Site, end_user_id: str | None) -> dict[str, Any]:
can_replace_logo = FeatureService.get_features(app_model.tenant_id).can_replace_logo
can_replace_logo = FeatureService.get_features(app_model.tenant_id, exclude_vector_space=True).can_replace_logo
app_site_info = AppSiteInfo(app_model.tenant, app_model, site, end_user_id, can_replace_logo)
return cast(dict[str, Any], marshal(app_site_info, AppSiteApi.app_fields))

View File

@ -534,7 +534,9 @@ class ProviderManager:
cache_key = f"tenant:{tenant_id}:model_load_balancing_enabled"
cache_result = redis_client.get(cache_key)
if cache_result is None:
model_load_balancing_enabled = FeatureService.get_features(tenant_id).model_load_balancing_enabled
model_load_balancing_enabled = FeatureService.get_features(
tenant_id, exclude_vector_space=True
).model_load_balancing_enabled
redis_client.setex(cache_key, 120, str(model_load_balancing_enabled))
else:
cache_result = cache_result.decode("utf-8")

View File

@ -58,7 +58,7 @@ def check_workspace_owner_transfer_permission(workspace_id: str) -> None:
Raises:
Forbidden: If either billing plan or workspace policy prohibits ownership transfer
"""
features = FeatureService.get_features(workspace_id)
features = FeatureService.get_features(workspace_id, exclude_vector_space=True)
if not features.is_allow_transfer_workspace:
raise Forbidden("Your current plan does not allow workspace ownership transfer")

View File

@ -112,7 +112,7 @@ def clean_unused_datasets_task():
features_cache_key = f"features:{dataset.tenant_id}"
plan_cache = redis_client.get(features_cache_key)
if plan_cache is None:
features = FeatureService.get_features(dataset.tenant_id)
features = FeatureService.get_features(dataset.tenant_id, exclude_vector_space=True)
redis_client.setex(features_cache_key, 600, features.billing.subscription.plan)
plan = features.billing.subscription.plan
else:

View File

@ -45,7 +45,7 @@ def mail_clean_document_notify_task():
dataset_auto_disable_logs_map[dataset_auto_disable_log.tenant_id].append(dataset_auto_disable_log)
url = f"{dify_config.CONSOLE_WEB_URL}/datasets"
for tenant_id, tenant_dataset_auto_disable_logs in dataset_auto_disable_logs_map.items():
features = FeatureService.get_features(tenant_id)
features = FeatureService.get_features(tenant_id, exclude_vector_space=True)
plan = features.billing.subscription.plan
if plan != CloudPlan.SANDBOX:
knowledge_details = []

View File

@ -521,7 +521,7 @@ class AppAnnotationService:
)
# Check annotation quota limit
features = FeatureService.get_features(current_tenant_id)
features = FeatureService.get_features(current_tenant_id, exclude_vector_space=True)
if features.billing.enabled:
annotation_quota_limit = features.annotation_quota_limit
if annotation_quota_limit.limit < len(result) + annotation_quota_limit.size:

View File

@ -1295,7 +1295,7 @@ class DatasetService:
def get_dataset_auto_disable_logs(dataset_id: str) -> AutoDisableLogsDict:
assert isinstance(current_user, Account)
assert current_user.current_tenant_id is not None
features = FeatureService.get_features(current_user.current_tenant_id)
features = FeatureService.get_features(current_user.current_tenant_id, exclude_vector_space=True)
if not features.billing.enabled or features.billing.subscription.plan == CloudPlan.SANDBOX:
return {
"document_ids": [],
@ -1977,7 +1977,7 @@ class DocumentService:
assert isinstance(current_user, Account)
assert current_user.current_tenant_id is not None
features = FeatureService.get_features(current_user.current_tenant_id)
features = FeatureService.get_features(current_user.current_tenant_id, exclude_vector_space=True)
if features.billing.enabled:
if not knowledge_config.original_document_id:
@ -2768,7 +2768,7 @@ class DocumentService:
assert current_user.current_tenant_id is not None
assert knowledge_config.data_source
features = FeatureService.get_features(current_user.current_tenant_id)
features = FeatureService.get_features(current_user.current_tenant_id, exclude_vector_space=True)
if features.billing.enabled:
count = 0

View File

@ -41,7 +41,7 @@ class DocumentTaskProxyBase(ABC):
@cached_property
def features(self):
return FeatureService.get_features(self._tenant_id)
return FeatureService.get_features(self._tenant_id, exclude_vector_space=True)
@abstractmethod
def _send_to_direct_queue(self, task_func: Callable[..., Any]):

View File

@ -136,7 +136,7 @@ class EmailDeliveryTestHandler:
) -> DeliveryTestResult:
if not isinstance(method, EmailDeliveryMethod):
raise DeliveryTestUnsupportedError("Delivery method does not support test send.")
features = FeatureService.get_features(context.tenant_id)
features = FeatureService.get_features(context.tenant_id, exclude_vector_space=True)
if not features.human_input_email_delivery_enabled:
raise DeliveryTestError("Email delivery is not available for current plan.")
if not mail.is_inited():

View File

@ -29,7 +29,7 @@ class RagPipelineTaskProxy:
@cached_property
def features(self):
return FeatureService.get_features(self._dataset_tenant_id)
return FeatureService.get_features(self._dataset_tenant_id, exclude_vector_space=True)
def _upload_invoke_entities(self) -> str:
text = [item.model_dump() for item in self._rag_pipeline_invoke_entities]

View File

@ -33,7 +33,7 @@ class WorkspaceService:
assert tenant_account_join is not None, "TenantAccountJoin not found"
tenant_info["role"] = tenant_account_join.role
feature = FeatureService.get_features(tenant.id)
feature = FeatureService.get_features(tenant.id, exclude_vector_space=True)
can_replace_logo = feature.can_replace_logo
if can_replace_logo and TenantService.has_roles(tenant, [TenantAccountRole.OWNER, TenantAccountRole.ADMIN]):

View File

@ -157,7 +157,7 @@ def dispatch_human_input_email_task(form_id: str, node_title: str | None = None,
if form is None:
logger.warning("Human input form not found, form_id=%s", form_id)
return
features = FeatureService.get_features(form.tenant_id)
features = FeatureService.get_features(form.tenant_id, exclude_vector_space=True)
if not features.human_input_email_delivery_enabled:
logger.info(
"Human input email delivery is not available for tenant=%s, form_id=%s",

View File

@ -166,11 +166,39 @@ class TestBillingResourceLimits:
with patch(
"controllers.console.wraps.current_account_with_tenant", return_value=(MockUser("test_user"), "tenant123")
):
with patch("controllers.console.wraps.FeatureService.get_features", return_value=mock_features):
with patch(
"controllers.console.wraps.FeatureService.get_features", return_value=mock_features
) as get_features:
result = add_member()
# Assert
assert result == "member_added"
get_features.assert_called_once_with("tenant123", exclude_vector_space=True)
def test_should_load_vector_space_for_vector_space_limit(self):
"""Test vector-space limit checks keep vector-space in feature payload."""
# Arrange
mock_features = MagicMock()
mock_features.billing.enabled = True
mock_features.vector_space.limit = 10
mock_features.vector_space.size = 5
@cloud_edition_billing_resource_check("vector_space")
def add_segment():
return "segment_added"
# Act
with patch(
"controllers.console.wraps.current_account_with_tenant", return_value=(MockUser("test_user"), "tenant123")
):
with patch(
"controllers.console.wraps.FeatureService.get_features", return_value=mock_features
) as get_features:
result = add_segment()
# Assert
assert result == "segment_added"
get_features.assert_called_once_with("tenant123", exclude_vector_space=False)
def test_should_reject_when_over_resource_limit(self):
"""Test that requests are rejected when over resource limits"""

View File

@ -139,7 +139,7 @@ class TestTenantListApi:
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
assert result["workspaces"][1]["plan"] == CloudPlan.PROFESSIONAL
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
get_features_mock.assert_called_once_with("t2")
get_features_mock.assert_called_once_with("t2", exclude_vector_space=True)
def test_get_saas_path_falls_back_to_legacy_feature_path_on_bulk_error(self, app: Flask):
"""Test fallback to FeatureService when bulk billing returns empty result.
@ -235,7 +235,7 @@ class TestTenantListApi:
assert status == 200
assert result["workspaces"][0]["plan"] == CloudPlan.SANDBOX
get_features_mock.assert_called_once_with("t1")
get_features_mock.assert_called_once_with("t1", exclude_vector_space=True)
def test_get_enterprise_only_skips_feature_service(self, app: Flask):
api = TenantListApi()

View File

@ -265,6 +265,34 @@ class TestCloudEditionBillingResourceCheck:
# Assert
assert result == "member_added"
mock_get_features.assert_called_once_with("tenant123", exclude_vector_space=True)
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@patch("controllers.service_api.wraps.FeatureService.get_features")
def test_loads_vector_space_when_checking_vector_space_limit(
self, mock_get_features, mock_validate_token, app: Flask
):
"""Test vector-space resource checks keep vector-space in feature payload."""
# Arrange
mock_validate_token.return_value = Mock(tenant_id="tenant123")
mock_features = Mock()
mock_features.billing.enabled = True
mock_features.vector_space.limit = 10
mock_features.vector_space.size = 5
mock_get_features.return_value = mock_features
@cloud_edition_billing_resource_check("vector_space", "dataset")
def add_segment():
return "segment_added"
# Act
with app.test_request_context("/", method="GET"):
result = add_segment()
# Assert
assert result == "segment_added"
mock_get_features.assert_called_once_with("tenant123", exclude_vector_space=False)
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@patch("controllers.service_api.wraps.FeatureService.get_features")

View File

@ -36,7 +36,7 @@ class TestWorkspacePermissionHelper:
# Should not raise
check_workspace_owner_transfer_permission("test-workspace-id")
mock_feature_service.get_features.assert_called_once_with("test-workspace-id")
mock_feature_service.get_features.assert_called_once_with("test-workspace-id", exclude_vector_space=True)
@patch("libs.workspace_permission.EnterpriseService")
@patch("libs.workspace_permission.dify_config")

View File

@ -344,7 +344,7 @@ class TestDispatchRouting:
proxy._dispatch()
# Assert
mock_features.assert_called_once_with(TENANT_ID)
mock_features.assert_called_once_with(TENANT_ID, exclude_vector_space=True)
class TestBaseRouterHelpers:

View File

@ -75,7 +75,7 @@ class TestDocumentIndexingTaskProxy:
assert features1 == mock_features
assert features2 == mock_features
assert features1 is features2 # Should be the same instance due to caching
mock_feature_service.get_features.assert_called_once_with("tenant-123")
mock_feature_service.get_features.assert_called_once_with("tenant-123", exclude_vector_space=True)
@patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task")
def test_send_to_direct_queue(self, mock_task):

View File

@ -94,7 +94,7 @@ class TestDuplicateDocumentIndexingTaskProxy:
assert features1 == mock_features
assert features2 == mock_features
assert features1 is features2 # Should be the same instance due to caching
mock_feature_service.get_features.assert_called_once_with("tenant-123")
mock_feature_service.get_features.assert_called_once_with("tenant-123", exclude_vector_space=True)
@patch(
"services.document_indexing_proxy.duplicate_document_indexing_task_proxy.normal_duplicate_document_indexing_task"

View File

@ -144,7 +144,7 @@ class TestRagPipelineTaskProxy:
assert features1 == mock_features
assert features2 == mock_features
assert features1 is features2 # Should be the same instance due to caching
mock_feature_service.get_features.assert_called_once_with("tenant-123")
mock_feature_service.get_features.assert_called_once_with("tenant-123", exclude_vector_space=True)
@patch("services.rag_pipeline.rag_pipeline_task_proxy.FileService")
@patch("services.rag_pipeline.rag_pipeline_task_proxy.db")