From 658ac155893a69822eb581e0c83d51e0480a6e86 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Fri, 20 Mar 2026 13:29:22 +0800 Subject: [PATCH 001/103] use new quota system --- api/enums/quota_type.py | 227 ++++++++++++++---------- api/services/app_generate_service.py | 3 +- api/services/async_workflow_service.py | 26 +-- api/services/billing_service.py | 54 +++++- api/services/trigger/webhook_service.py | 19 +- api/tasks/trigger_processing_tasks.py | 5 +- api/tasks/workflow_schedule_tasks.py | 3 +- 7 files changed, 224 insertions(+), 113 deletions(-) diff --git a/api/enums/quota_type.py b/api/enums/quota_type.py index 9f511b88ef..305f5b9808 100644 --- a/api/enums/quota_type.py +++ b/api/enums/quota_type.py @@ -1,5 +1,6 @@ import logging -from dataclasses import dataclass +import uuid +from dataclasses import dataclass, field from enum import StrEnum, auto logger = logging.getLogger(__name__) @@ -8,49 +9,85 @@ logger = logging.getLogger(__name__) @dataclass class QuotaCharge: """ - Result of a quota consumption operation. + Result of a quota reservation (Reserve phase). - Attributes: - success: Whether the quota charge succeeded - charge_id: UUID for refund, or None if failed/disabled + Lifecycle: + charge = QuotaType.TRIGGER.consume(tenant_id) # Reserve + try: + do_work() + charge.commit() # Confirm consumption + except: + charge.refund() # Release frozen quota + + If neither commit() nor refund() is called, the billing system's + cleanup CronJob will auto-release the reservation within ~75 seconds. """ success: bool - charge_id: str | None + charge_id: str | None # reservation_id _quota_type: "QuotaType" + _tenant_id: str | None = None + _feature_key: str | None = None + _amount: int = 0 + _committed: bool = field(default=False, repr=False) + + def commit(self, actual_amount: int | None = None) -> None: + """ + Confirm the consumption with actual amount. + + Args: + actual_amount: Actual amount consumed. Defaults to the reserved amount. + If less than reserved, the difference is refunded automatically. + """ + if self._committed or not self.charge_id: + return + + try: + from services.billing_service import BillingService + + amount = actual_amount if actual_amount is not None else self._amount + BillingService.quota_commit( + tenant_id=self._tenant_id, + feature_key=self._feature_key, + reservation_id=self.charge_id, + actual_amount=amount, + ) + self._committed = True + logger.debug( + "Committed %s quota for tenant %s, reservation_id: %s, amount: %d", + self._quota_type.value, self._tenant_id, self.charge_id, amount, + ) + except Exception: + logger.exception("Failed to commit quota, reservation_id: %s", self.charge_id) def refund(self) -> None: """ - Refund this quota charge. + Release the reserved quota (cancel the charge). + + Safe to call even if: + - charge failed or was disabled (charge_id is None) + - already committed (Release after Commit is a no-op) + - already refunded (idempotent) - Safe to call even if charge failed or was disabled. This method guarantees no exceptions will be raised. """ - if self.charge_id: - self._quota_type.refund(self.charge_id) - logger.info("Refunded quota for %s with charge_id: %s", self._quota_type.value, self.charge_id) + if not self.charge_id or not self._tenant_id or not self._feature_key: + return + + self._quota_type.release(self.charge_id, self._tenant_id, self._feature_key) class QuotaType(StrEnum): """ Supported quota types for tenant feature usage. - - Add additional types here whenever new billable features become available. """ - # Trigger execution quota TRIGGER = auto() - - # Workflow execution quota WORKFLOW = auto() - UNLIMITED = auto() @property def billing_key(self) -> str: - """ - Get the billing key for the feature. - """ match self: case QuotaType.TRIGGER: return "trigger_event" @@ -61,14 +98,45 @@ class QuotaType(StrEnum): def consume(self, tenant_id: str, amount: int = 1) -> QuotaCharge: """ - Consume quota for the feature. + Consume quota using Reserve + immediate Commit. + + This is the simple one-shot mode: Reserve freezes quota, then Commit + confirms it right away. The returned QuotaCharge supports .refund() + which calls Release (idempotent even after Commit). + + For advanced two-phase usage (e.g. streaming), use reserve() directly + and call charge.commit() / charge.refund() manually. Args: tenant_id: The tenant identifier amount: Amount to consume (default: 1) Returns: - QuotaCharge with success status and charge_id for refund + QuotaCharge with reservation_id for potential refund + + Raises: + QuotaExceededError: When quota is insufficient + """ + charge = self.reserve(tenant_id, amount) + if charge.success and charge.charge_id: + charge.commit() + return charge + + def reserve(self, tenant_id: str, amount: int = 1) -> QuotaCharge: + """ + Reserve quota before task execution (Reserve phase only). + + The caller MUST call charge.commit() after the task succeeds, + or charge.refund() if the task fails. + + If neither is called, the reservation auto-expires in ~75 seconds. + + Args: + tenant_id: The tenant identifier + amount: Amount to reserve (default: 1) + + Returns: + QuotaCharge — call .commit() on success, .refund() on failure Raises: QuotaExceededError: When quota is insufficient @@ -81,51 +149,52 @@ class QuotaType(StrEnum): logger.debug("Billing disabled, allowing request for %s", tenant_id) return QuotaCharge(success=True, charge_id=None, _quota_type=self) - logger.info("Consuming %d %s quota for tenant %s", amount, self.value, tenant_id) + logger.info("Reserving %d %s quota for tenant %s", amount, self.value, tenant_id) if amount <= 0: - raise ValueError("Amount to consume must be greater than 0") + raise ValueError("Amount to reserve must be greater than 0") + + request_id = str(uuid.uuid4()) + feature_key = self.billing_key try: - response = BillingService.update_tenant_feature_plan_usage(tenant_id, self.billing_key, delta=amount) + reserve_resp = BillingService.quota_reserve( + tenant_id=tenant_id, + feature_key=feature_key, + request_id=request_id, + amount=amount, + ) - if response.get("result") != "success": + reservation_id = reserve_resp.get("reservation_id") + if not reservation_id: logger.warning( - "Failed to consume quota for %s, feature %s details: %s", - tenant_id, - self.value, - response.get("detail"), + "Reserve returned no reservation_id for %s, feature %s, response: %s", + tenant_id, self.value, reserve_resp, ) raise QuotaExceededError(feature=self.value, tenant_id=tenant_id, required=amount) - charge_id = response.get("history_id") logger.debug( - "Successfully consumed %d %s quota for tenant %s, charge_id: %s", - amount, - self.value, - tenant_id, - charge_id, + "Reserved %d %s quota for tenant %s, reservation_id: %s", + amount, self.value, tenant_id, reservation_id, + ) + return QuotaCharge( + success=True, + charge_id=reservation_id, + _quota_type=self, + _tenant_id=tenant_id, + _feature_key=feature_key, + _amount=amount, ) - return QuotaCharge(success=True, charge_id=charge_id, _quota_type=self) except QuotaExceededError: raise + except ValueError: + raise except Exception: - # fail-safe: allow request on billing errors - logger.exception("Failed to consume quota for %s, feature %s", tenant_id, self.value) + logger.exception("Failed to reserve quota for %s, feature %s", tenant_id, self.value) return unlimited() def check(self, tenant_id: str, amount: int = 1) -> bool: - """ - Check if tenant has sufficient quota without consuming. - - Args: - tenant_id: The tenant identifier - amount: Amount to check (default: 1) - - Returns: - True if quota is sufficient, False otherwise - """ from configs import dify_config if not dify_config.BILLING_ENABLED: @@ -139,18 +208,11 @@ class QuotaType(StrEnum): return remaining >= amount if remaining != -1 else True except Exception: logger.exception("Failed to check quota for %s, feature %s", tenant_id, self.value) - # fail-safe: allow request on billing errors return True - def refund(self, charge_id: str) -> None: + def release(self, reservation_id: str, tenant_id: str, feature_key: str) -> None: """ - Refund quota using charge_id from consume(). - - This method guarantees no exceptions will be raised. - All errors are logged but silently handled. - - Args: - charge_id: The UUID returned from consume() + Release a reservation. Guarantees no exceptions. """ try: from configs import dify_config @@ -159,51 +221,36 @@ class QuotaType(StrEnum): if not dify_config.BILLING_ENABLED: return - if not charge_id: - logger.warning("Cannot refund: charge_id is empty") + if not reservation_id: return - logger.info("Refunding %s quota with charge_id: %s", self.value, charge_id) - - response = BillingService.refund_tenant_feature_plan_usage(charge_id) - if response.get("result") == "success": - logger.debug("Successfully refunded %s quota, charge_id: %s", self.value, charge_id) - else: - logger.warning("Refund failed for charge_id: %s", charge_id) - + logger.info("Releasing %s quota, reservation_id: %s", self.value, reservation_id) + BillingService.quota_release( + tenant_id=tenant_id, + feature_key=feature_key, + reservation_id=reservation_id, + ) except Exception: - # Catch ALL exceptions - refund must never fail - logger.exception("Failed to refund quota for charge_id: %s", charge_id) - # Don't raise - refund is best-effort and must be silent + logger.exception("Failed to release quota, reservation_id: %s", reservation_id) def get_remaining(self, tenant_id: str) -> int: - """ - Get remaining quota for the tenant. - - Args: - tenant_id: The tenant identifier - - Returns: - Remaining quota amount - """ from services.billing_service import BillingService try: - usage_info = BillingService.get_tenant_feature_plan_usage(tenant_id, self.billing_key) - # Assuming the API returns a dict with 'remaining' or 'limit' and 'used' + usage_info = BillingService.get_tenant_feature_plan_usage_info(tenant_id) if isinstance(usage_info, dict): - return usage_info.get("remaining", 0) - # If it returns a simple number, treat it as remaining - return int(usage_info) if usage_info else 0 + feature_info = usage_info.get(self.billing_key, {}) + if isinstance(feature_info, dict): + limit = feature_info.get("limit", 0) + usage = feature_info.get("usage", 0) + if limit == -1: + return -1 + return max(0, limit - usage) + return 0 except Exception: logger.exception("Failed to get remaining quota for %s, feature %s", tenant_id, self.value) return -1 def unlimited() -> QuotaCharge: - """ - Return a quota charge for unlimited quota. - - This is useful for features that are not subject to quota limits, such as the UNLIMITED quota type. - """ return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED) diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 40013f2b66..ce68a1dcba 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -106,7 +106,7 @@ class AppGenerateService: quota_charge = unlimited() if dify_config.BILLING_ENABLED: try: - quota_charge = QuotaType.WORKFLOW.consume(app_model.tenant_id) + quota_charge = QuotaType.WORKFLOW.reserve(app_model.tenant_id) except QuotaExceededError: raise InvokeRateLimitError(f"Workflow execution quota limit reached for tenant {app_model.tenant_id}") @@ -116,6 +116,7 @@ class AppGenerateService: request_id = RateLimit.gen_request_key() try: request_id = rate_limit.enter(request_id) + quota_charge.commit() if app_model.mode == AppMode.COMPLETION: return rate_limit.generate( CompletionAppGenerator.convert_to_event_stream( diff --git a/api/services/async_workflow_service.py b/api/services/async_workflow_service.py index 0133634e5a..5f7201de11 100644 --- a/api/services/async_workflow_service.py +++ b/api/services/async_workflow_service.py @@ -13,7 +13,7 @@ from celery.result import AsyncResult from sqlalchemy import select from sqlalchemy.orm import Session -from enums.quota_type import QuotaType +from enums.quota_type import QuotaType, unlimited from extensions.ext_database import db from models.account import Account from models.enums import CreatorUserRole, WorkflowTriggerStatus @@ -131,9 +131,10 @@ class AsyncWorkflowService: trigger_log = trigger_log_repo.create(trigger_log) session.commit() - # 7. Check and consume quota + # 7. Reserve quota (commit after successful dispatch) + quota_charge = unlimited() try: - QuotaType.WORKFLOW.consume(trigger_data.tenant_id) + quota_charge = QuotaType.WORKFLOW.reserve(trigger_data.tenant_id) except QuotaExceededError as e: # Update trigger log status trigger_log.status = WorkflowTriggerStatus.RATE_LIMITED @@ -153,13 +154,18 @@ class AsyncWorkflowService: # 9. Dispatch to appropriate queue task_data_dict = task_data.model_dump(mode="json") - task: AsyncResult[Any] | None = None - if queue_name == QueuePriority.PROFESSIONAL: - task = execute_workflow_professional.delay(task_data_dict) - elif queue_name == QueuePriority.TEAM: - task = execute_workflow_team.delay(task_data_dict) - else: # SANDBOX - task = execute_workflow_sandbox.delay(task_data_dict) + try: + task: AsyncResult[Any] | None = None + if queue_name == QueuePriority.PROFESSIONAL: + task = execute_workflow_professional.delay(task_data_dict) + elif queue_name == QueuePriority.TEAM: + task = execute_workflow_team.delay(task_data_dict) + else: # SANDBOX + task = execute_workflow_sandbox.delay(task_data_dict) + quota_charge.commit() + except Exception: + quota_charge.refund() + raise # 10. Update trigger log with task info trigger_log.status = WorkflowTriggerStatus.QUEUED diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 5ab47c799a..4648d675c8 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -47,10 +47,60 @@ class BillingService: @classmethod def get_tenant_feature_plan_usage_info(cls, tenant_id: str): params = {"tenant_id": tenant_id} - - usage_info = cls._send_request("GET", "/tenant-feature-usage/info", params=params) + usage_info = cls._send_request("GET", "/quota/info", params=params) return usage_info + @classmethod + def quota_reserve( + cls, tenant_id: str, feature_key: str, request_id: str, amount: int = 1, meta: dict | None = None + ) -> dict: + """Reserve quota before task execution. + + Returns: + {"reservation_id": "uuid", "available": int, "reserved": int} + """ + payload = { + "tenant_id": tenant_id, + "feature_key": feature_key, + "request_id": request_id, + "amount": amount, + } + if meta: + payload["meta"] = meta + return cls._send_request("POST", "/quota/reserve", json=payload) + + @classmethod + def quota_commit( + cls, tenant_id: str, feature_key: str, reservation_id: str, actual_amount: int, meta: dict | None = None + ) -> dict: + """Commit a reservation with actual consumption. + + Returns: + {"available": int, "reserved": int, "refunded": int} + """ + payload = { + "tenant_id": tenant_id, + "feature_key": feature_key, + "reservation_id": reservation_id, + "actual_amount": actual_amount, + } + if meta: + payload["meta"] = meta + return cls._send_request("POST", "/quota/commit", json=payload) + + @classmethod + def quota_release(cls, tenant_id: str, feature_key: str, reservation_id: str) -> dict: + """Release a reservation (cancel, return frozen quota). + + Returns: + {"available": int, "reserved": int, "released": int} + """ + return cls._send_request("POST", "/quota/release", json={ + "tenant_id": tenant_id, + "feature_key": feature_key, + "reservation_id": reservation_id, + }) + @classmethod def get_knowledge_rate_limit(cls, tenant_id: str): params = {"tenant_id": tenant_id} diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index 3c1a4cc747..3bc64423b3 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -779,9 +779,9 @@ class WebhookService: user_id=None, ) - # consume quota before triggering workflow execution + # reserve quota before triggering workflow execution try: - QuotaType.TRIGGER.consume(webhook_trigger.tenant_id) + quota_charge = QuotaType.TRIGGER.reserve(webhook_trigger.tenant_id) except QuotaExceededError: AppTriggerService.mark_tenant_triggers_rate_limited(webhook_trigger.tenant_id) logger.info( @@ -792,11 +792,16 @@ class WebhookService: raise # Trigger workflow execution asynchronously - AsyncWorkflowService.trigger_workflow_async( - session, - end_user, - trigger_data, - ) + try: + AsyncWorkflowService.trigger_workflow_async( + session, + end_user, + trigger_data, + ) + quota_charge.commit() + except Exception: + quota_charge.refund() + raise except Exception: logger.exception("Failed to trigger workflow for webhook %s", webhook_trigger.webhook_id) diff --git a/api/tasks/trigger_processing_tasks.py b/api/tasks/trigger_processing_tasks.py index 75ae1f6316..3a9a663759 100644 --- a/api/tasks/trigger_processing_tasks.py +++ b/api/tasks/trigger_processing_tasks.py @@ -298,10 +298,10 @@ def dispatch_triggered_workflow( icon_dark_filename=trigger_entity.identity.icon_dark or "", ) - # consume quota before invoking trigger + # reserve quota before invoking trigger quota_charge = unlimited() try: - quota_charge = QuotaType.TRIGGER.consume(subscription.tenant_id) + quota_charge = QuotaType.TRIGGER.reserve(subscription.tenant_id) except QuotaExceededError: AppTriggerService.mark_tenant_triggers_rate_limited(subscription.tenant_id) logger.info( @@ -387,6 +387,7 @@ def dispatch_triggered_workflow( raise ValueError(f"End user not found for app {plugin_trigger.app_id}") AsyncWorkflowService.trigger_workflow_async(session=session, user=end_user, trigger_data=trigger_data) + quota_charge.commit() dispatched_count += 1 logger.info( "Triggered workflow for app %s with trigger event %s", diff --git a/api/tasks/workflow_schedule_tasks.py b/api/tasks/workflow_schedule_tasks.py index 8c64d3ab27..9aa90c3793 100644 --- a/api/tasks/workflow_schedule_tasks.py +++ b/api/tasks/workflow_schedule_tasks.py @@ -43,7 +43,7 @@ def run_schedule_trigger(schedule_id: str) -> None: quota_charge = unlimited() try: - quota_charge = QuotaType.TRIGGER.consume(schedule.tenant_id) + quota_charge = QuotaType.TRIGGER.reserve(schedule.tenant_id) except QuotaExceededError: AppTriggerService.mark_tenant_triggers_rate_limited(schedule.tenant_id) logger.info("Tenant %s rate limited, skipping schedule trigger %s", schedule.tenant_id, schedule_id) @@ -61,6 +61,7 @@ def run_schedule_trigger(schedule_id: str) -> None: tenant_id=schedule.tenant_id, ), ) + quota_charge.commit() logger.info("Schedule %s triggered workflow: %s", schedule_id, response.workflow_trigger_log_id) except Exception as e: quota_charge.refund() From 3888969af3aae0ee9cdcc8dadf450456004fbd26 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 05:45:30 +0000 Subject: [PATCH 002/103] [autofix.ci] apply automated fixes --- api/enums/quota_type.py | 14 +++++++++++--- api/services/billing_service.py | 14 +++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/api/enums/quota_type.py b/api/enums/quota_type.py index 305f5b9808..4caf9ec98b 100644 --- a/api/enums/quota_type.py +++ b/api/enums/quota_type.py @@ -55,7 +55,10 @@ class QuotaCharge: self._committed = True logger.debug( "Committed %s quota for tenant %s, reservation_id: %s, amount: %d", - self._quota_type.value, self._tenant_id, self.charge_id, amount, + self._quota_type.value, + self._tenant_id, + self.charge_id, + amount, ) except Exception: logger.exception("Failed to commit quota, reservation_id: %s", self.charge_id) @@ -169,13 +172,18 @@ class QuotaType(StrEnum): if not reservation_id: logger.warning( "Reserve returned no reservation_id for %s, feature %s, response: %s", - tenant_id, self.value, reserve_resp, + tenant_id, + self.value, + reserve_resp, ) raise QuotaExceededError(feature=self.value, tenant_id=tenant_id, required=amount) logger.debug( "Reserved %d %s quota for tenant %s, reservation_id: %s", - amount, self.value, tenant_id, reservation_id, + amount, + self.value, + tenant_id, + reservation_id, ) return QuotaCharge( success=True, diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 4648d675c8..2bddd3ab11 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -95,11 +95,15 @@ class BillingService: Returns: {"available": int, "reserved": int, "released": int} """ - return cls._send_request("POST", "/quota/release", json={ - "tenant_id": tenant_id, - "feature_key": feature_key, - "reservation_id": reservation_id, - }) + return cls._send_request( + "POST", + "/quota/release", + json={ + "tenant_id": tenant_id, + "feature_key": feature_key, + "reservation_id": reservation_id, + }, + ) @classmethod def get_knowledge_rate_limit(cls, tenant_id: str): From 80b4633e8f226511215ad8d1ab72f9bf96effa55 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Fri, 20 Mar 2026 14:58:31 +0800 Subject: [PATCH 003/103] fix style check and test --- api/enums/quota_type.py | 2 +- api/services/billing_service.py | 4 ++-- .../services/test_app_generate_service.py | 18 +++++++++++++----- .../services/test_app_generate_service.py | 11 ++++++----- .../services/test_async_workflow_service.py | 8 ++++++-- .../services/test_billing_service.py | 2 +- 6 files changed, 29 insertions(+), 16 deletions(-) diff --git a/api/enums/quota_type.py b/api/enums/quota_type.py index 4caf9ec98b..a2a7f689a5 100644 --- a/api/enums/quota_type.py +++ b/api/enums/quota_type.py @@ -39,7 +39,7 @@ class QuotaCharge: actual_amount: Actual amount consumed. Defaults to the reserved amount. If less than reserved, the difference is refunded automatically. """ - if self._committed or not self.charge_id: + if self._committed or not self.charge_id or not self._tenant_id or not self._feature_key: return try: diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 2bddd3ab11..6fe61a1a52 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -59,7 +59,7 @@ class BillingService: Returns: {"reservation_id": "uuid", "available": int, "reserved": int} """ - payload = { + payload: dict = { "tenant_id": tenant_id, "feature_key": feature_key, "request_id": request_id, @@ -78,7 +78,7 @@ class BillingService: Returns: {"available": int, "reserved": int, "refunded": int} """ - payload = { + payload: dict = { "tenant_id": tenant_id, "feature_key": feature_key, "reservation_id": reservation_id, diff --git a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py index 5b1a4790f5..eea9673710 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py @@ -39,9 +39,15 @@ class TestAppGenerateService: patch("configs.dify_config", autospec=True) as mock_global_dify_config, ): # Setup default mock returns for billing service - mock_billing_service.update_tenant_feature_plan_usage.return_value = { - "result": "success", - "history_id": "test_history_id", + mock_billing_service.quota_reserve.return_value = { + "reservation_id": "test-reservation-id", + "available": 100, + "reserved": 1, + } + mock_billing_service.quota_commit.return_value = { + "available": 99, + "reserved": 0, + "refunded": 0, } # Setup default mock returns for workflow service @@ -478,8 +484,10 @@ class TestAppGenerateService: # Verify the result assert result == ["test_response"] - # Verify billing service was called to consume quota - mock_external_service_dependencies["billing_service"].update_tenant_feature_plan_usage.assert_called_once() + # Verify billing two-phase quota (reserve + commit) + billing = mock_external_service_dependencies["billing_service"] + billing.quota_reserve.assert_called_once() + billing.quota_commit.assert_called_once() def test_generate_with_invalid_app_mode( self, db_session_with_containers: Session, mock_external_service_dependencies diff --git a/api/tests/unit_tests/services/test_app_generate_service.py b/api/tests/unit_tests/services/test_app_generate_service.py index c2b430c551..68ee6ae9d6 100644 --- a/api/tests/unit_tests/services/test_app_generate_service.py +++ b/api/tests/unit_tests/services/test_app_generate_service.py @@ -447,8 +447,8 @@ class TestGenerateBilling: def test_billing_enabled_consumes_quota(self, mocker, monkeypatch): monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) quota_charge = MagicMock() - consume_mock = mocker.patch( - "services.app_generate_service.QuotaType.WORKFLOW.consume", + reserve_mock = mocker.patch( + "services.app_generate_service.QuotaType.WORKFLOW.reserve", return_value=quota_charge, ) mocker.patch( @@ -467,7 +467,8 @@ class TestGenerateBilling: invoke_from=InvokeFrom.SERVICE_API, streaming=False, ) - consume_mock.assert_called_once_with("tenant-id") + reserve_mock.assert_called_once_with("tenant-id") + quota_charge.commit.assert_called_once() def test_billing_quota_exceeded_raises_rate_limit_error(self, mocker, monkeypatch): from services.errors.app import QuotaExceededError @@ -475,7 +476,7 @@ class TestGenerateBilling: monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) mocker.patch( - "services.app_generate_service.QuotaType.WORKFLOW.consume", + "services.app_generate_service.QuotaType.WORKFLOW.reserve", side_effect=QuotaExceededError(feature="workflow", tenant_id="t", required=1), ) @@ -492,7 +493,7 @@ class TestGenerateBilling: monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) quota_charge = MagicMock() mocker.patch( - "services.app_generate_service.QuotaType.WORKFLOW.consume", + "services.app_generate_service.QuotaType.WORKFLOW.reserve", return_value=quota_charge, ) mocker.patch( diff --git a/api/tests/unit_tests/services/test_async_workflow_service.py b/api/tests/unit_tests/services/test_async_workflow_service.py index 639e091041..ab83c8020f 100644 --- a/api/tests/unit_tests/services/test_async_workflow_service.py +++ b/api/tests/unit_tests/services/test_async_workflow_service.py @@ -146,6 +146,9 @@ class TestAsyncWorkflowService: mocks["team_task"].delay.return_value = task_result mocks["sandbox_task"].delay.return_value = task_result + quota_charge_mock = MagicMock() + mocks["quota_workflow"].reserve.return_value = quota_charge_mock + class DummyAccount: def __init__(self, user_id: str): self.id = user_id @@ -163,7 +166,8 @@ class TestAsyncWorkflowService: assert result.status == "queued" assert result.queue == queue_name - mocks["quota_workflow"].consume.assert_called_once_with("tenant-123") + mocks["quota_workflow"].reserve.assert_called_once_with("tenant-123") + quota_charge_mock.commit.assert_called_once() assert session.commit.call_count == 2 created_log = mocks["repo"].create.call_args[0][0] @@ -250,7 +254,7 @@ class TestAsyncWorkflowService: mocks = async_workflow_trigger_mocks mocks["dispatcher"].get_queue_name.return_value = QueuePriority.TEAM mocks["get_workflow"].return_value = workflow - mocks["quota_workflow"].consume.side_effect = QuotaExceededError( + mocks["quota_workflow"].reserve.side_effect = QuotaExceededError( feature="workflow", tenant_id="tenant-123", required=1, diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index eecb3c7672..135c2e9962 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -426,7 +426,7 @@ class TestBillingServiceUsageCalculation: # Assert assert result == expected_response - mock_send_request.assert_called_once_with("GET", "/tenant-feature-usage/info", params={"tenant_id": tenant_id}) + mock_send_request.assert_called_once_with("GET", "/quota/info", params={"tenant_id": tenant_id}) def test_update_tenant_feature_plan_usage_positive_delta(self, mock_send_request): """Test updating tenant feature usage with positive delta (adding credits).""" From f1bcd6d71532f7390a419b5814dbac9e92854748 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Mon, 30 Mar 2026 16:41:56 +0800 Subject: [PATCH 004/103] add test case for quota and billing service --- api/tests/unit_tests/enums/__init__.py | 0 api/tests/unit_tests/enums/test_quota_type.py | 358 ++++++++++++++++++ .../services/test_billing_service.py | 81 ++++ 3 files changed, 439 insertions(+) create mode 100644 api/tests/unit_tests/enums/__init__.py create mode 100644 api/tests/unit_tests/enums/test_quota_type.py diff --git a/api/tests/unit_tests/enums/__init__.py b/api/tests/unit_tests/enums/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/enums/test_quota_type.py b/api/tests/unit_tests/enums/test_quota_type.py new file mode 100644 index 0000000000..8a3bb349ac --- /dev/null +++ b/api/tests/unit_tests/enums/test_quota_type.py @@ -0,0 +1,358 @@ +"""Unit tests for QuotaType and QuotaCharge.""" + +from unittest.mock import patch + +import pytest + +from enums.quota_type import QuotaCharge, QuotaType, unlimited + + +class TestQuotaType: + def test_billing_key_trigger(self): + assert QuotaType.TRIGGER.billing_key == "trigger_event" + + def test_billing_key_workflow(self): + assert QuotaType.WORKFLOW.billing_key == "api_rate_limit" + + def test_billing_key_unlimited_raises(self): + with pytest.raises(ValueError, match="Invalid quota type"): + _ = QuotaType.UNLIMITED.billing_key + + def test_reserve_billing_disabled(self): + with ( + patch("configs.dify_config") as mock_cfg, + patch("services.billing_service.BillingService"), + ): + mock_cfg.BILLING_ENABLED = False + charge = QuotaType.TRIGGER.reserve("t1") + assert charge.success is True + assert charge.charge_id is None + + def test_reserve_zero_amount_raises(self): + with patch("configs.dify_config") as mock_cfg: + mock_cfg.BILLING_ENABLED = True + with pytest.raises(ValueError, match="greater than 0"): + QuotaType.TRIGGER.reserve("t1", amount=0) + + def test_reserve_success(self): + with ( + patch("configs.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + mock_bs.quota_reserve.return_value = {"reservation_id": "rid-1", "available": 99} + + charge = QuotaType.TRIGGER.reserve("t1", amount=1) + + assert charge.success is True + assert charge.charge_id == "rid-1" + assert charge._tenant_id == "t1" + assert charge._feature_key == "trigger_event" + assert charge._amount == 1 + mock_bs.quota_reserve.assert_called_once() + + def test_reserve_no_reservation_id_raises(self): + from services.errors.app import QuotaExceededError + + with ( + patch("configs.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + mock_bs.quota_reserve.return_value = {} + + with pytest.raises(QuotaExceededError): + QuotaType.TRIGGER.reserve("t1") + + def test_reserve_quota_exceeded_propagates(self): + from services.errors.app import QuotaExceededError + + with ( + patch("configs.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + mock_bs.quota_reserve.side_effect = QuotaExceededError( + feature="trigger", tenant_id="t1", required=1 + ) + + with pytest.raises(QuotaExceededError): + QuotaType.TRIGGER.reserve("t1") + + def test_reserve_api_exception_returns_unlimited(self): + with ( + patch("configs.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + mock_bs.quota_reserve.side_effect = RuntimeError("network") + + charge = QuotaType.TRIGGER.reserve("t1") + assert charge.success is True + assert charge.charge_id is None + + def test_consume_calls_reserve_and_commit(self): + with ( + patch("configs.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + mock_bs.quota_reserve.return_value = {"reservation_id": "rid-c"} + mock_bs.quota_commit.return_value = {} + + charge = QuotaType.TRIGGER.consume("t1") + assert charge.success is True + mock_bs.quota_commit.assert_called_once() + + def test_check_billing_disabled(self): + with patch("configs.dify_config") as mock_cfg: + mock_cfg.BILLING_ENABLED = False + assert QuotaType.TRIGGER.check("t1") is True + + def test_check_zero_amount_raises(self): + with patch("configs.dify_config") as mock_cfg: + mock_cfg.BILLING_ENABLED = True + with pytest.raises(ValueError, match="greater than 0"): + QuotaType.TRIGGER.check("t1", amount=0) + + def test_check_sufficient_quota(self): + with ( + patch("configs.dify_config") as mock_cfg, + patch.object(QuotaType.TRIGGER, "get_remaining", return_value=100), + ): + mock_cfg.BILLING_ENABLED = True + assert QuotaType.TRIGGER.check("t1", amount=50) is True + + def test_check_insufficient_quota(self): + with ( + patch("configs.dify_config") as mock_cfg, + patch.object(QuotaType.TRIGGER, "get_remaining", return_value=5), + ): + mock_cfg.BILLING_ENABLED = True + assert QuotaType.TRIGGER.check("t1", amount=10) is False + + def test_check_unlimited_quota(self): + with ( + patch("configs.dify_config") as mock_cfg, + patch.object(QuotaType.TRIGGER, "get_remaining", return_value=-1), + ): + mock_cfg.BILLING_ENABLED = True + assert QuotaType.TRIGGER.check("t1", amount=999) is True + + def test_check_exception_returns_true(self): + with ( + patch("configs.dify_config") as mock_cfg, + patch.object(QuotaType.TRIGGER, "get_remaining", side_effect=RuntimeError), + ): + mock_cfg.BILLING_ENABLED = True + assert QuotaType.TRIGGER.check("t1") is True + + def test_release_billing_disabled(self): + with ( + patch("configs.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = False + QuotaType.TRIGGER.release("rid-1", "t1", "trigger_event") + mock_bs.quota_release.assert_not_called() + + def test_release_empty_reservation(self): + with ( + patch("configs.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + QuotaType.TRIGGER.release("", "t1", "trigger_event") + mock_bs.quota_release.assert_not_called() + + def test_release_success(self): + with ( + patch("configs.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + mock_bs.quota_release.return_value = {} + QuotaType.TRIGGER.release("rid-1", "t1", "trigger_event") + mock_bs.quota_release.assert_called_once_with( + tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1" + ) + + def test_release_exception_swallowed(self): + with ( + patch("configs.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + mock_bs.quota_release.side_effect = RuntimeError("fail") + QuotaType.TRIGGER.release("rid-1", "t1", "trigger_event") + + def test_get_remaining_normal(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_tenant_feature_plan_usage_info.return_value = { + "trigger_event": {"limit": 100, "usage": 30} + } + assert QuotaType.TRIGGER.get_remaining("t1") == 70 + + def test_get_remaining_unlimited(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_tenant_feature_plan_usage_info.return_value = { + "trigger_event": {"limit": -1, "usage": 0} + } + assert QuotaType.TRIGGER.get_remaining("t1") == -1 + + def test_get_remaining_over_limit_returns_zero(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_tenant_feature_plan_usage_info.return_value = { + "trigger_event": {"limit": 10, "usage": 15} + } + assert QuotaType.TRIGGER.get_remaining("t1") == 0 + + def test_get_remaining_exception_returns_neg1(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_tenant_feature_plan_usage_info.side_effect = RuntimeError + assert QuotaType.TRIGGER.get_remaining("t1") == -1 + + def test_get_remaining_empty_response(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_tenant_feature_plan_usage_info.return_value = {} + assert QuotaType.TRIGGER.get_remaining("t1") == 0 + + def test_get_remaining_non_dict_response(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_tenant_feature_plan_usage_info.return_value = "invalid" + assert QuotaType.TRIGGER.get_remaining("t1") == 0 + + def test_get_remaining_feature_not_in_response(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_tenant_feature_plan_usage_info.return_value = { + "other_feature": {"limit": 100, "usage": 0} + } + remaining = QuotaType.TRIGGER.get_remaining("t1") + assert remaining == 0 + + def test_get_remaining_non_dict_feature_info(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_tenant_feature_plan_usage_info.return_value = { + "trigger_event": "not_a_dict" + } + assert QuotaType.TRIGGER.get_remaining("t1") == 0 + + +class TestQuotaCharge: + def test_commit_success(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.quota_commit.return_value = {} + charge = QuotaCharge( + success=True, + charge_id="rid-1", + _quota_type=QuotaType.TRIGGER, + _tenant_id="t1", + _feature_key="trigger_event", + _amount=1, + ) + charge.commit() + mock_bs.quota_commit.assert_called_once_with( + tenant_id="t1", + feature_key="trigger_event", + reservation_id="rid-1", + actual_amount=1, + ) + assert charge._committed is True + + def test_commit_with_actual_amount(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.quota_commit.return_value = {} + charge = QuotaCharge( + success=True, + charge_id="rid-1", + _quota_type=QuotaType.TRIGGER, + _tenant_id="t1", + _feature_key="trigger_event", + _amount=10, + ) + charge.commit(actual_amount=5) + call_kwargs = mock_bs.quota_commit.call_args[1] + assert call_kwargs["actual_amount"] == 5 + + def test_commit_idempotent(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.quota_commit.return_value = {} + charge = QuotaCharge( + success=True, + charge_id="rid-1", + _quota_type=QuotaType.TRIGGER, + _tenant_id="t1", + _feature_key="trigger_event", + _amount=1, + ) + charge.commit() + charge.commit() + assert mock_bs.quota_commit.call_count == 1 + + def test_commit_no_charge_id_noop(self): + with patch("services.billing_service.BillingService") as mock_bs: + charge = QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.TRIGGER) + charge.commit() + mock_bs.quota_commit.assert_not_called() + + def test_commit_no_tenant_id_noop(self): + with patch("services.billing_service.BillingService") as mock_bs: + charge = QuotaCharge( + success=True, + charge_id="rid-1", + _quota_type=QuotaType.TRIGGER, + _tenant_id=None, + _feature_key="trigger_event", + ) + charge.commit() + mock_bs.quota_commit.assert_not_called() + + def test_commit_exception_swallowed(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.quota_commit.side_effect = RuntimeError("fail") + charge = QuotaCharge( + success=True, + charge_id="rid-1", + _quota_type=QuotaType.TRIGGER, + _tenant_id="t1", + _feature_key="trigger_event", + _amount=1, + ) + charge.commit() + + def test_refund_success(self): + with patch.object(QuotaType.TRIGGER, "release") as mock_rel: + charge = QuotaCharge( + success=True, + charge_id="rid-1", + _quota_type=QuotaType.TRIGGER, + _tenant_id="t1", + _feature_key="trigger_event", + ) + charge.refund() + mock_rel.assert_called_once_with("rid-1", "t1", "trigger_event") + + def test_refund_no_charge_id_noop(self): + with patch.object(QuotaType.TRIGGER, "release") as mock_rel: + charge = QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.TRIGGER) + charge.refund() + mock_rel.assert_not_called() + + def test_refund_no_tenant_id_noop(self): + with patch.object(QuotaType.TRIGGER, "release") as mock_rel: + charge = QuotaCharge( + success=True, + charge_id="rid-1", + _quota_type=QuotaType.TRIGGER, + _tenant_id=None, + ) + charge.refund() + mock_rel.assert_not_called() + + +class TestUnlimited: + def test_unlimited_returns_success_with_no_charge_id(self): + charge = unlimited() + assert charge.success is True + assert charge.charge_id is None + assert charge._quota_type == QuotaType.UNLIMITED diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index 252b898c70..d7d496031b 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -505,6 +505,87 @@ class TestBillingServiceUsageCalculation: ) +class TestBillingServiceQuotaOperations: + """Unit tests for quota reserve/commit/release operations.""" + + @pytest.fixture + def mock_send_request(self): + with patch.object(BillingService, "_send_request") as mock: + yield mock + + def test_quota_reserve_success(self, mock_send_request): + expected = {"reservation_id": "rid-1", "available": 99, "reserved": 1} + mock_send_request.return_value = expected + + result = BillingService.quota_reserve( + tenant_id="t1", feature_key="trigger_event", request_id="req-1", amount=1 + ) + + assert result == expected + mock_send_request.assert_called_once_with( + "POST", + "/quota/reserve", + json={"tenant_id": "t1", "feature_key": "trigger_event", "request_id": "req-1", "amount": 1}, + ) + + def test_quota_reserve_with_meta(self, mock_send_request): + mock_send_request.return_value = {"reservation_id": "rid-2"} + meta = {"source": "webhook"} + + BillingService.quota_reserve( + tenant_id="t1", feature_key="trigger_event", request_id="req-2", amount=1, meta=meta + ) + + call_json = mock_send_request.call_args[1]["json"] + assert call_json["meta"] == {"source": "webhook"} + + def test_quota_commit_success(self, mock_send_request): + expected = {"available": 98, "reserved": 0, "refunded": 0} + mock_send_request.return_value = expected + + result = BillingService.quota_commit( + tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1", actual_amount=1 + ) + + assert result == expected + mock_send_request.assert_called_once_with( + "POST", + "/quota/commit", + json={ + "tenant_id": "t1", + "feature_key": "trigger_event", + "reservation_id": "rid-1", + "actual_amount": 1, + }, + ) + + def test_quota_commit_with_meta(self, mock_send_request): + mock_send_request.return_value = {} + meta = {"reason": "partial"} + + BillingService.quota_commit( + tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1", actual_amount=1, meta=meta + ) + + call_json = mock_send_request.call_args[1]["json"] + assert call_json["meta"] == {"reason": "partial"} + + def test_quota_release_success(self, mock_send_request): + expected = {"available": 100, "reserved": 0, "released": 1} + mock_send_request.return_value = expected + + result = BillingService.quota_release( + tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1" + ) + + assert result == expected + mock_send_request.assert_called_once_with( + "POST", + "/quota/release", + json={"tenant_id": "t1", "feature_key": "trigger_event", "reservation_id": "rid-1"}, + ) + + class TestBillingServiceRateLimitEnforcement: """Unit tests for rate limit enforcement mechanisms. From b95cdabe26bddfdd739b20138b1a47720971e7dc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:45:37 +0000 Subject: [PATCH 005/103] [autofix.ci] apply automated fixes --- api/tests/unit_tests/enums/test_quota_type.py | 24 +++++-------------- .../services/test_billing_service.py | 8 ++----- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/api/tests/unit_tests/enums/test_quota_type.py b/api/tests/unit_tests/enums/test_quota_type.py index 8a3bb349ac..ac34064f8a 100644 --- a/api/tests/unit_tests/enums/test_quota_type.py +++ b/api/tests/unit_tests/enums/test_quota_type.py @@ -72,9 +72,7 @@ class TestQuotaType: patch("services.billing_service.BillingService") as mock_bs, ): mock_cfg.BILLING_ENABLED = True - mock_bs.quota_reserve.side_effect = QuotaExceededError( - feature="trigger", tenant_id="t1", required=1 - ) + mock_bs.quota_reserve.side_effect = QuotaExceededError(feature="trigger", tenant_id="t1", required=1) with pytest.raises(QuotaExceededError): QuotaType.TRIGGER.reserve("t1") @@ -188,23 +186,17 @@ class TestQuotaType: def test_get_remaining_normal(self): with patch("services.billing_service.BillingService") as mock_bs: - mock_bs.get_tenant_feature_plan_usage_info.return_value = { - "trigger_event": {"limit": 100, "usage": 30} - } + mock_bs.get_tenant_feature_plan_usage_info.return_value = {"trigger_event": {"limit": 100, "usage": 30}} assert QuotaType.TRIGGER.get_remaining("t1") == 70 def test_get_remaining_unlimited(self): with patch("services.billing_service.BillingService") as mock_bs: - mock_bs.get_tenant_feature_plan_usage_info.return_value = { - "trigger_event": {"limit": -1, "usage": 0} - } + mock_bs.get_tenant_feature_plan_usage_info.return_value = {"trigger_event": {"limit": -1, "usage": 0}} assert QuotaType.TRIGGER.get_remaining("t1") == -1 def test_get_remaining_over_limit_returns_zero(self): with patch("services.billing_service.BillingService") as mock_bs: - mock_bs.get_tenant_feature_plan_usage_info.return_value = { - "trigger_event": {"limit": 10, "usage": 15} - } + mock_bs.get_tenant_feature_plan_usage_info.return_value = {"trigger_event": {"limit": 10, "usage": 15}} assert QuotaType.TRIGGER.get_remaining("t1") == 0 def test_get_remaining_exception_returns_neg1(self): @@ -224,17 +216,13 @@ class TestQuotaType: def test_get_remaining_feature_not_in_response(self): with patch("services.billing_service.BillingService") as mock_bs: - mock_bs.get_tenant_feature_plan_usage_info.return_value = { - "other_feature": {"limit": 100, "usage": 0} - } + mock_bs.get_tenant_feature_plan_usage_info.return_value = {"other_feature": {"limit": 100, "usage": 0}} remaining = QuotaType.TRIGGER.get_remaining("t1") assert remaining == 0 def test_get_remaining_non_dict_feature_info(self): with patch("services.billing_service.BillingService") as mock_bs: - mock_bs.get_tenant_feature_plan_usage_info.return_value = { - "trigger_event": "not_a_dict" - } + mock_bs.get_tenant_feature_plan_usage_info.return_value = {"trigger_event": "not_a_dict"} assert QuotaType.TRIGGER.get_remaining("t1") == 0 diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index d7d496031b..3ae2e006e3 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -517,9 +517,7 @@ class TestBillingServiceQuotaOperations: expected = {"reservation_id": "rid-1", "available": 99, "reserved": 1} mock_send_request.return_value = expected - result = BillingService.quota_reserve( - tenant_id="t1", feature_key="trigger_event", request_id="req-1", amount=1 - ) + result = BillingService.quota_reserve(tenant_id="t1", feature_key="trigger_event", request_id="req-1", amount=1) assert result == expected mock_send_request.assert_called_once_with( @@ -574,9 +572,7 @@ class TestBillingServiceQuotaOperations: expected = {"available": 100, "reserved": 0, "released": 1} mock_send_request.return_value = expected - result = BillingService.quota_release( - tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1" - ) + result = BillingService.quota_release(tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1") assert result == expected mock_send_request.assert_called_once_with( From 4e50d55339cb105f57d36a345aded1fef98ebe46 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Tue, 31 Mar 2026 09:49:09 +0800 Subject: [PATCH 006/103] fix comment --- api/enums/quota_type.py | 86 +----------------- api/services/app_generate_service.py | 3 +- api/services/async_workflow_service.py | 3 +- api/services/billing_service.py | 77 ++++++++++------ api/services/quota_service.py | 90 +++++++++++++++++++ api/tasks/trigger_processing_tasks.py | 3 +- api/tasks/workflow_schedule_tasks.py | 3 +- api/tests/unit_tests/enums/test_quota_type.py | 19 ++-- .../services/test_billing_service.py | 55 +++++++++++- 9 files changed, 212 insertions(+), 127 deletions(-) create mode 100644 api/services/quota_service.py diff --git a/api/enums/quota_type.py b/api/enums/quota_type.py index a2a7f689a5..95146d17d9 100644 --- a/api/enums/quota_type.py +++ b/api/enums/quota_type.py @@ -1,85 +1,10 @@ import logging import uuid -from dataclasses import dataclass, field from enum import StrEnum, auto logger = logging.getLogger(__name__) -@dataclass -class QuotaCharge: - """ - Result of a quota reservation (Reserve phase). - - Lifecycle: - charge = QuotaType.TRIGGER.consume(tenant_id) # Reserve - try: - do_work() - charge.commit() # Confirm consumption - except: - charge.refund() # Release frozen quota - - If neither commit() nor refund() is called, the billing system's - cleanup CronJob will auto-release the reservation within ~75 seconds. - """ - - success: bool - charge_id: str | None # reservation_id - _quota_type: "QuotaType" - _tenant_id: str | None = None - _feature_key: str | None = None - _amount: int = 0 - _committed: bool = field(default=False, repr=False) - - def commit(self, actual_amount: int | None = None) -> None: - """ - Confirm the consumption with actual amount. - - Args: - actual_amount: Actual amount consumed. Defaults to the reserved amount. - If less than reserved, the difference is refunded automatically. - """ - if self._committed or not self.charge_id or not self._tenant_id or not self._feature_key: - return - - try: - from services.billing_service import BillingService - - amount = actual_amount if actual_amount is not None else self._amount - BillingService.quota_commit( - tenant_id=self._tenant_id, - feature_key=self._feature_key, - reservation_id=self.charge_id, - actual_amount=amount, - ) - self._committed = True - logger.debug( - "Committed %s quota for tenant %s, reservation_id: %s, amount: %d", - self._quota_type.value, - self._tenant_id, - self.charge_id, - amount, - ) - except Exception: - logger.exception("Failed to commit quota, reservation_id: %s", self.charge_id) - - def refund(self) -> None: - """ - Release the reserved quota (cancel the charge). - - Safe to call even if: - - charge failed or was disabled (charge_id is None) - - already committed (Release after Commit is a no-op) - - already refunded (idempotent) - - This method guarantees no exceptions will be raised. - """ - if not self.charge_id or not self._tenant_id or not self._feature_key: - return - - self._quota_type.release(self.charge_id, self._tenant_id, self._feature_key) - - class QuotaType(StrEnum): """ Supported quota types for tenant feature usage. @@ -99,7 +24,7 @@ class QuotaType(StrEnum): case _: raise ValueError(f"Invalid quota type: {self}") - def consume(self, tenant_id: str, amount: int = 1) -> QuotaCharge: + def consume(self, tenant_id: str, amount: int = 1): """ Consume quota using Reserve + immediate Commit. @@ -125,7 +50,7 @@ class QuotaType(StrEnum): charge.commit() return charge - def reserve(self, tenant_id: str, amount: int = 1) -> QuotaCharge: + def reserve(self, tenant_id: str, amount: int = 1): """ Reserve quota before task execution (Reserve phase only). @@ -147,6 +72,7 @@ class QuotaType(StrEnum): from configs import dify_config from services.billing_service import BillingService from services.errors.app import QuotaExceededError + from services.quota_service import QuotaCharge, unlimited if not dify_config.BILLING_ENABLED: logger.debug("Billing disabled, allowing request for %s", tenant_id) @@ -245,7 +171,7 @@ class QuotaType(StrEnum): from services.billing_service import BillingService try: - usage_info = BillingService.get_tenant_feature_plan_usage_info(tenant_id) + usage_info = BillingService.get_quota_info(tenant_id) if isinstance(usage_info, dict): feature_info = usage_info.get(self.billing_key, {}) if isinstance(feature_info, dict): @@ -258,7 +184,3 @@ class QuotaType(StrEnum): except Exception: logger.exception("Failed to get remaining quota for %s, feature %s", tenant_id, self.value) return -1 - - -def unlimited() -> QuotaCharge: - return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED) diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index ce68a1dcba..246252fda0 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -18,7 +18,8 @@ from core.app.features.rate_limiting import RateLimit from core.app.features.rate_limiting.rate_limit import rate_limit_context from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig from core.db import session_factory -from enums.quota_type import QuotaType, unlimited +from enums.quota_type import QuotaType +from services.quota_service import unlimited from extensions.otel import AppGenerateHandler, trace_span from models.model import Account, App, AppMode, EndUser from models.workflow import Workflow, WorkflowRun diff --git a/api/services/async_workflow_service.py b/api/services/async_workflow_service.py index 5f7201de11..d9a468795c 100644 --- a/api/services/async_workflow_service.py +++ b/api/services/async_workflow_service.py @@ -13,7 +13,8 @@ from celery.result import AsyncResult from sqlalchemy import select from sqlalchemy.orm import Session -from enums.quota_type import QuotaType, unlimited +from enums.quota_type import QuotaType +from services.quota_service import unlimited from extensions.ext_database import db from models.account import Account from models.enums import CreatorUserRole, WorkflowTriggerStatus diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 755407d849..717b818d52 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -26,6 +26,29 @@ class SubscriptionPlan(TypedDict): expiration_date: int +class QuotaReserveResult(TypedDict): + reservation_id: str + available: int + reserved: int + + +class QuotaCommitResult(TypedDict): + available: int + reserved: int + refunded: int + + +class QuotaReleaseResult(TypedDict): + available: int + reserved: int + released: int + + +_quota_reserve_adapter = TypeAdapter(QuotaReserveResult) +_quota_commit_adapter = TypeAdapter(QuotaCommitResult) +_quota_release_adapter = TypeAdapter(QuotaReleaseResult) + + class BillingService: base_url = os.environ.get("BILLING_API_URL", "BILLING_API_URL") secret_key = os.environ.get("BILLING_API_SECRET_KEY", "BILLING_API_SECRET_KEY") @@ -46,19 +69,21 @@ class BillingService: @classmethod def get_tenant_feature_plan_usage_info(cls, tenant_id: str): + """Deprecated: Use get_quota_info instead.""" params = {"tenant_id": tenant_id} - usage_info = cls._send_request("GET", "/quota/info", params=params) + usage_info = cls._send_request("GET", "/tenant-feature-usage/info", params=params) return usage_info + @classmethod + def get_quota_info(cls, tenant_id: str): + params = {"tenant_id": tenant_id} + return cls._send_request("GET", "/quota/info", params=params) + @classmethod def quota_reserve( cls, tenant_id: str, feature_key: str, request_id: str, amount: int = 1, meta: dict | None = None - ) -> dict: - """Reserve quota before task execution. - - Returns: - {"reservation_id": "uuid", "available": int, "reserved": int} - """ + ) -> QuotaReserveResult: + """Reserve quota before task execution.""" payload: dict = { "tenant_id": tenant_id, "feature_key": feature_key, @@ -67,17 +92,13 @@ class BillingService: } if meta: payload["meta"] = meta - return cls._send_request("POST", "/quota/reserve", json=payload) + return _quota_reserve_adapter.validate_python(cls._send_request("POST", "/quota/reserve", json=payload)) @classmethod def quota_commit( cls, tenant_id: str, feature_key: str, reservation_id: str, actual_amount: int, meta: dict | None = None - ) -> dict: - """Commit a reservation with actual consumption. - - Returns: - {"available": int, "reserved": int, "refunded": int} - """ + ) -> QuotaCommitResult: + """Commit a reservation with actual consumption.""" payload: dict = { "tenant_id": tenant_id, "feature_key": feature_key, @@ -86,23 +107,21 @@ class BillingService: } if meta: payload["meta"] = meta - return cls._send_request("POST", "/quota/commit", json=payload) + return _quota_commit_adapter.validate_python(cls._send_request("POST", "/quota/commit", json=payload)) @classmethod - def quota_release(cls, tenant_id: str, feature_key: str, reservation_id: str) -> dict: - """Release a reservation (cancel, return frozen quota). - - Returns: - {"available": int, "reserved": int, "released": int} - """ - return cls._send_request( - "POST", - "/quota/release", - json={ - "tenant_id": tenant_id, - "feature_key": feature_key, - "reservation_id": reservation_id, - }, + def quota_release(cls, tenant_id: str, feature_key: str, reservation_id: str) -> QuotaReleaseResult: + """Release a reservation (cancel, return frozen quota).""" + return _quota_release_adapter.validate_python( + cls._send_request( + "POST", + "/quota/release", + json={ + "tenant_id": tenant_id, + "feature_key": feature_key, + "reservation_id": reservation_id, + }, + ) ) @classmethod diff --git a/api/services/quota_service.py b/api/services/quota_service.py new file mode 100644 index 0000000000..77cfdda3a3 --- /dev/null +++ b/api/services/quota_service.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from enums.quota_type import QuotaType + +logger = logging.getLogger(__name__) + + +@dataclass +class QuotaCharge: + """ + Result of a quota reservation (Reserve phase). + + Lifecycle: + charge = QuotaType.TRIGGER.consume(tenant_id) # Reserve + try: + do_work() + charge.commit() # Confirm consumption + except: + charge.refund() # Release frozen quota + + If neither commit() nor refund() is called, the billing system's + cleanup CronJob will auto-release the reservation within ~75 seconds. + """ + + success: bool + charge_id: str | None # reservation_id + _quota_type: QuotaType + _tenant_id: str | None = None + _feature_key: str | None = None + _amount: int = 0 + _committed: bool = field(default=False, repr=False) + + def commit(self, actual_amount: int | None = None) -> None: + """ + Confirm the consumption with actual amount. + + Args: + actual_amount: Actual amount consumed. Defaults to the reserved amount. + If less than reserved, the difference is refunded automatically. + """ + if self._committed or not self.charge_id or not self._tenant_id or not self._feature_key: + return + + try: + from services.billing_service import BillingService + + amount = actual_amount if actual_amount is not None else self._amount + BillingService.quota_commit( + tenant_id=self._tenant_id, + feature_key=self._feature_key, + reservation_id=self.charge_id, + actual_amount=amount, + ) + self._committed = True + logger.debug( + "Committed %s quota for tenant %s, reservation_id: %s, amount: %d", + self._quota_type, + self._tenant_id, + self.charge_id, + amount, + ) + except Exception: + logger.exception("Failed to commit quota, reservation_id: %s", self.charge_id) + + def refund(self) -> None: + """ + Release the reserved quota (cancel the charge). + + Safe to call even if: + - charge failed or was disabled (charge_id is None) + - already committed (Release after Commit is a no-op) + - already refunded (idempotent) + + This method guarantees no exceptions will be raised. + """ + if not self.charge_id or not self._tenant_id or not self._feature_key: + return + + self._quota_type.release(self.charge_id, self._tenant_id, self._feature_key) + + +def unlimited() -> QuotaCharge: + from enums.quota_type import QuotaType + + return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED) diff --git a/api/tasks/trigger_processing_tasks.py b/api/tasks/trigger_processing_tasks.py index aca2b93fc9..a3f72da6f5 100644 --- a/api/tasks/trigger_processing_tasks.py +++ b/api/tasks/trigger_processing_tasks.py @@ -28,7 +28,8 @@ from core.trigger.entities.entities import TriggerProviderEntity from core.trigger.provider import PluginTriggerProviderController from core.trigger.trigger_manager import TriggerManager from core.workflow.nodes.trigger_plugin.entities import TriggerEventNodeData -from enums.quota_type import QuotaType, unlimited +from enums.quota_type import QuotaType +from services.quota_service import unlimited from models.enums import ( AppTriggerType, CreatorUserRole, diff --git a/api/tasks/workflow_schedule_tasks.py b/api/tasks/workflow_schedule_tasks.py index 9aa90c3793..2816bd04ab 100644 --- a/api/tasks/workflow_schedule_tasks.py +++ b/api/tasks/workflow_schedule_tasks.py @@ -8,7 +8,8 @@ from core.workflow.nodes.trigger_schedule.exc import ( ScheduleNotFoundError, TenantOwnerNotFoundError, ) -from enums.quota_type import QuotaType, unlimited +from enums.quota_type import QuotaType +from services.quota_service import unlimited from models.trigger import WorkflowSchedulePlan from services.async_workflow_service import AsyncWorkflowService from services.errors.app import QuotaExceededError diff --git a/api/tests/unit_tests/enums/test_quota_type.py b/api/tests/unit_tests/enums/test_quota_type.py index ac34064f8a..85b5b8bb3e 100644 --- a/api/tests/unit_tests/enums/test_quota_type.py +++ b/api/tests/unit_tests/enums/test_quota_type.py @@ -4,7 +4,8 @@ from unittest.mock import patch import pytest -from enums.quota_type import QuotaCharge, QuotaType, unlimited +from enums.quota_type import QuotaType +from services.quota_service import QuotaCharge, unlimited class TestQuotaType: @@ -186,43 +187,43 @@ class TestQuotaType: def test_get_remaining_normal(self): with patch("services.billing_service.BillingService") as mock_bs: - mock_bs.get_tenant_feature_plan_usage_info.return_value = {"trigger_event": {"limit": 100, "usage": 30}} + mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": 100, "usage": 30}} assert QuotaType.TRIGGER.get_remaining("t1") == 70 def test_get_remaining_unlimited(self): with patch("services.billing_service.BillingService") as mock_bs: - mock_bs.get_tenant_feature_plan_usage_info.return_value = {"trigger_event": {"limit": -1, "usage": 0}} + mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": -1, "usage": 0}} assert QuotaType.TRIGGER.get_remaining("t1") == -1 def test_get_remaining_over_limit_returns_zero(self): with patch("services.billing_service.BillingService") as mock_bs: - mock_bs.get_tenant_feature_plan_usage_info.return_value = {"trigger_event": {"limit": 10, "usage": 15}} + mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": 10, "usage": 15}} assert QuotaType.TRIGGER.get_remaining("t1") == 0 def test_get_remaining_exception_returns_neg1(self): with patch("services.billing_service.BillingService") as mock_bs: - mock_bs.get_tenant_feature_plan_usage_info.side_effect = RuntimeError + mock_bs.get_quota_info.side_effect = RuntimeError assert QuotaType.TRIGGER.get_remaining("t1") == -1 def test_get_remaining_empty_response(self): with patch("services.billing_service.BillingService") as mock_bs: - mock_bs.get_tenant_feature_plan_usage_info.return_value = {} + mock_bs.get_quota_info.return_value = {} assert QuotaType.TRIGGER.get_remaining("t1") == 0 def test_get_remaining_non_dict_response(self): with patch("services.billing_service.BillingService") as mock_bs: - mock_bs.get_tenant_feature_plan_usage_info.return_value = "invalid" + mock_bs.get_quota_info.return_value = "invalid" assert QuotaType.TRIGGER.get_remaining("t1") == 0 def test_get_remaining_feature_not_in_response(self): with patch("services.billing_service.BillingService") as mock_bs: - mock_bs.get_tenant_feature_plan_usage_info.return_value = {"other_feature": {"limit": 100, "usage": 0}} + mock_bs.get_quota_info.return_value = {"other_feature": {"limit": 100, "usage": 0}} remaining = QuotaType.TRIGGER.get_remaining("t1") assert remaining == 0 def test_get_remaining_non_dict_feature_info(self): with patch("services.billing_service.BillingService") as mock_bs: - mock_bs.get_tenant_feature_plan_usage_info.return_value = {"trigger_event": "not_a_dict"} + mock_bs.get_quota_info.return_value = {"trigger_event": "not_a_dict"} assert QuotaType.TRIGGER.get_remaining("t1") == 0 diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index 3ae2e006e3..ed78397dc3 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -415,7 +415,7 @@ class TestBillingServiceUsageCalculation: yield mock def test_get_tenant_feature_plan_usage_info(self, mock_send_request): - """Test retrieval of tenant feature plan usage information.""" + """Test retrieval of tenant feature plan usage information (legacy endpoint).""" # Arrange tenant_id = "tenant-123" expected_response = {"features": {"trigger": {"used": 50, "limit": 100}, "workflow": {"used": 20, "limit": 50}}} @@ -424,6 +424,20 @@ class TestBillingServiceUsageCalculation: # Act result = BillingService.get_tenant_feature_plan_usage_info(tenant_id) + # Assert + assert result == expected_response + mock_send_request.assert_called_once_with("GET", "/tenant-feature-usage/info", params={"tenant_id": tenant_id}) + + def test_get_quota_info(self, mock_send_request): + """Test retrieval of quota info from new endpoint.""" + # Arrange + tenant_id = "tenant-123" + expected_response = {"trigger_event": {"limit": 100, "usage": 30}, "api_rate_limit": {"limit": -1, "usage": 0}} + mock_send_request.return_value = expected_response + + # Act + result = BillingService.get_quota_info(tenant_id) + # Assert assert result == expected_response mock_send_request.assert_called_once_with("GET", "/quota/info", params={"tenant_id": tenant_id}) @@ -526,8 +540,19 @@ class TestBillingServiceQuotaOperations: json={"tenant_id": "t1", "feature_key": "trigger_event", "request_id": "req-1", "amount": 1}, ) + def test_quota_reserve_coerces_string_to_int(self, mock_send_request): + """Test that TypeAdapter coerces string values to int.""" + mock_send_request.return_value = {"reservation_id": "rid-str", "available": "99", "reserved": "1"} + + result = BillingService.quota_reserve(tenant_id="t1", feature_key="trigger_event", request_id="req-s", amount=1) + + assert result["available"] == 99 + assert isinstance(result["available"], int) + assert result["reserved"] == 1 + assert isinstance(result["reserved"], int) + def test_quota_reserve_with_meta(self, mock_send_request): - mock_send_request.return_value = {"reservation_id": "rid-2"} + mock_send_request.return_value = {"reservation_id": "rid-2", "available": 98, "reserved": 1} meta = {"source": "webhook"} BillingService.quota_reserve( @@ -557,8 +582,21 @@ class TestBillingServiceQuotaOperations: }, ) + def test_quota_commit_coerces_string_to_int(self, mock_send_request): + """Test that TypeAdapter coerces string values to int.""" + mock_send_request.return_value = {"available": "97", "reserved": "0", "refunded": "1"} + + result = BillingService.quota_commit( + tenant_id="t1", feature_key="trigger_event", reservation_id="rid-s", actual_amount=1 + ) + + assert result["available"] == 97 + assert isinstance(result["available"], int) + assert result["refunded"] == 1 + assert isinstance(result["refunded"], int) + def test_quota_commit_with_meta(self, mock_send_request): - mock_send_request.return_value = {} + mock_send_request.return_value = {"available": 97, "reserved": 0, "refunded": 0} meta = {"reason": "partial"} BillingService.quota_commit( @@ -581,6 +619,17 @@ class TestBillingServiceQuotaOperations: json={"tenant_id": "t1", "feature_key": "trigger_event", "reservation_id": "rid-1"}, ) + def test_quota_release_coerces_string_to_int(self, mock_send_request): + """Test that TypeAdapter coerces string values to int.""" + mock_send_request.return_value = {"available": "100", "reserved": "0", "released": "1"} + + result = BillingService.quota_release(tenant_id="t1", feature_key="trigger_event", reservation_id="rid-s") + + assert result["available"] == 100 + assert isinstance(result["available"], int) + assert result["released"] == 1 + assert isinstance(result["released"], int) + class TestBillingServiceRateLimitEnforcement: """Unit tests for rate limit enforcement mechanisms. From 6e745f9e9b85d9f9ec2ee7f20751da597047ad53 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Tue, 31 Mar 2026 09:49:24 +0800 Subject: [PATCH 007/103] fix linter --- api/services/app_generate_service.py | 2 +- api/services/async_workflow_service.py | 2 +- api/tasks/trigger_processing_tasks.py | 2 +- api/tasks/workflow_schedule_tasks.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 246252fda0..ee24424cd5 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -19,12 +19,12 @@ from core.app.features.rate_limiting.rate_limit import rate_limit_context from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig from core.db import session_factory from enums.quota_type import QuotaType -from services.quota_service import unlimited from extensions.otel import AppGenerateHandler, trace_span from models.model import Account, App, AppMode, EndUser from models.workflow import Workflow, WorkflowRun from services.errors.app import QuotaExceededError, WorkflowIdFormatError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError +from services.quota_service import unlimited from services.workflow_service import WorkflowService from tasks.app_generate.workflow_execute_task import AppExecutionParams, workflow_based_app_execution_task diff --git a/api/services/async_workflow_service.py b/api/services/async_workflow_service.py index d9a468795c..e78e03cce5 100644 --- a/api/services/async_workflow_service.py +++ b/api/services/async_workflow_service.py @@ -14,7 +14,6 @@ from sqlalchemy import select from sqlalchemy.orm import Session from enums.quota_type import QuotaType -from services.quota_service import unlimited from extensions.ext_database import db from models.account import Account from models.enums import CreatorUserRole, WorkflowTriggerStatus @@ -23,6 +22,7 @@ from models.trigger import WorkflowTriggerLog, WorkflowTriggerLogDict from models.workflow import Workflow from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository from services.errors.app import QuotaExceededError, WorkflowNotFoundError, WorkflowQuotaLimitError +from services.quota_service import unlimited from services.workflow.entities import AsyncTriggerResponse, TriggerData, WorkflowTaskData from services.workflow.queue_dispatcher import QueueDispatcherManager, QueuePriority from services.workflow_service import WorkflowService diff --git a/api/tasks/trigger_processing_tasks.py b/api/tasks/trigger_processing_tasks.py index a3f72da6f5..1b1e809baf 100644 --- a/api/tasks/trigger_processing_tasks.py +++ b/api/tasks/trigger_processing_tasks.py @@ -29,7 +29,6 @@ from core.trigger.provider import PluginTriggerProviderController from core.trigger.trigger_manager import TriggerManager from core.workflow.nodes.trigger_plugin.entities import TriggerEventNodeData from enums.quota_type import QuotaType -from services.quota_service import unlimited from models.enums import ( AppTriggerType, CreatorUserRole, @@ -43,6 +42,7 @@ from models.workflow import Workflow, WorkflowAppLog, WorkflowAppLogCreatedFrom, from services.async_workflow_service import AsyncWorkflowService from services.end_user_service import EndUserService from services.errors.app import QuotaExceededError +from services.quota_service import unlimited from services.trigger.app_trigger_service import AppTriggerService from services.trigger.trigger_provider_service import TriggerProviderService from services.trigger.trigger_request_service import TriggerHttpRequestCachingService diff --git a/api/tasks/workflow_schedule_tasks.py b/api/tasks/workflow_schedule_tasks.py index 2816bd04ab..774e1103df 100644 --- a/api/tasks/workflow_schedule_tasks.py +++ b/api/tasks/workflow_schedule_tasks.py @@ -9,10 +9,10 @@ from core.workflow.nodes.trigger_schedule.exc import ( TenantOwnerNotFoundError, ) from enums.quota_type import QuotaType -from services.quota_service import unlimited from models.trigger import WorkflowSchedulePlan from services.async_workflow_service import AsyncWorkflowService from services.errors.app import QuotaExceededError +from services.quota_service import unlimited from services.trigger.app_trigger_service import AppTriggerService from services.trigger.schedule_service import ScheduleService from services.workflow.entities import ScheduleTriggerData From 422bf3506e4ff71a1b1a394fc1d5162e52dec02e Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Tue, 31 Mar 2026 14:59:45 +0800 Subject: [PATCH 008/103] rebuild quota service --- api/enums/quota_type.py | 165 ------------------ api/services/app_generate_service.py | 4 +- api/services/async_workflow_service.py | 4 +- api/services/quota_service.py | 147 +++++++++++++++- api/services/trigger/webhook_service.py | 3 +- api/tasks/trigger_processing_tasks.py | 4 +- api/tasks/workflow_schedule_tasks.py | 4 +- .../trigger/test_trigger_e2e.py | 4 +- api/tests/unit_tests/enums/test_quota_type.py | 106 +++++------ .../services/test_app_generate_service.py | 6 +- .../services/test_async_workflow_service.py | 16 +- 11 files changed, 222 insertions(+), 241 deletions(-) diff --git a/api/enums/quota_type.py b/api/enums/quota_type.py index 95146d17d9..a10ac21f69 100644 --- a/api/enums/quota_type.py +++ b/api/enums/quota_type.py @@ -1,9 +1,5 @@ -import logging -import uuid from enum import StrEnum, auto -logger = logging.getLogger(__name__) - class QuotaType(StrEnum): """ @@ -23,164 +19,3 @@ class QuotaType(StrEnum): return "api_rate_limit" case _: raise ValueError(f"Invalid quota type: {self}") - - def consume(self, tenant_id: str, amount: int = 1): - """ - Consume quota using Reserve + immediate Commit. - - This is the simple one-shot mode: Reserve freezes quota, then Commit - confirms it right away. The returned QuotaCharge supports .refund() - which calls Release (idempotent even after Commit). - - For advanced two-phase usage (e.g. streaming), use reserve() directly - and call charge.commit() / charge.refund() manually. - - Args: - tenant_id: The tenant identifier - amount: Amount to consume (default: 1) - - Returns: - QuotaCharge with reservation_id for potential refund - - Raises: - QuotaExceededError: When quota is insufficient - """ - charge = self.reserve(tenant_id, amount) - if charge.success and charge.charge_id: - charge.commit() - return charge - - def reserve(self, tenant_id: str, amount: int = 1): - """ - Reserve quota before task execution (Reserve phase only). - - The caller MUST call charge.commit() after the task succeeds, - or charge.refund() if the task fails. - - If neither is called, the reservation auto-expires in ~75 seconds. - - Args: - tenant_id: The tenant identifier - amount: Amount to reserve (default: 1) - - Returns: - QuotaCharge — call .commit() on success, .refund() on failure - - Raises: - QuotaExceededError: When quota is insufficient - """ - from configs import dify_config - from services.billing_service import BillingService - from services.errors.app import QuotaExceededError - from services.quota_service import QuotaCharge, unlimited - - if not dify_config.BILLING_ENABLED: - logger.debug("Billing disabled, allowing request for %s", tenant_id) - return QuotaCharge(success=True, charge_id=None, _quota_type=self) - - logger.info("Reserving %d %s quota for tenant %s", amount, self.value, tenant_id) - - if amount <= 0: - raise ValueError("Amount to reserve must be greater than 0") - - request_id = str(uuid.uuid4()) - feature_key = self.billing_key - - try: - reserve_resp = BillingService.quota_reserve( - tenant_id=tenant_id, - feature_key=feature_key, - request_id=request_id, - amount=amount, - ) - - reservation_id = reserve_resp.get("reservation_id") - if not reservation_id: - logger.warning( - "Reserve returned no reservation_id for %s, feature %s, response: %s", - tenant_id, - self.value, - reserve_resp, - ) - raise QuotaExceededError(feature=self.value, tenant_id=tenant_id, required=amount) - - logger.debug( - "Reserved %d %s quota for tenant %s, reservation_id: %s", - amount, - self.value, - tenant_id, - reservation_id, - ) - return QuotaCharge( - success=True, - charge_id=reservation_id, - _quota_type=self, - _tenant_id=tenant_id, - _feature_key=feature_key, - _amount=amount, - ) - - except QuotaExceededError: - raise - except ValueError: - raise - except Exception: - logger.exception("Failed to reserve quota for %s, feature %s", tenant_id, self.value) - return unlimited() - - def check(self, tenant_id: str, amount: int = 1) -> bool: - from configs import dify_config - - if not dify_config.BILLING_ENABLED: - return True - - if amount <= 0: - raise ValueError("Amount to check must be greater than 0") - - try: - remaining = self.get_remaining(tenant_id) - return remaining >= amount if remaining != -1 else True - except Exception: - logger.exception("Failed to check quota for %s, feature %s", tenant_id, self.value) - return True - - def release(self, reservation_id: str, tenant_id: str, feature_key: str) -> None: - """ - Release a reservation. Guarantees no exceptions. - """ - try: - from configs import dify_config - from services.billing_service import BillingService - - if not dify_config.BILLING_ENABLED: - return - - if not reservation_id: - return - - logger.info("Releasing %s quota, reservation_id: %s", self.value, reservation_id) - BillingService.quota_release( - tenant_id=tenant_id, - feature_key=feature_key, - reservation_id=reservation_id, - ) - except Exception: - logger.exception("Failed to release quota, reservation_id: %s", reservation_id) - - def get_remaining(self, tenant_id: str) -> int: - from services.billing_service import BillingService - - try: - usage_info = BillingService.get_quota_info(tenant_id) - if isinstance(usage_info, dict): - feature_info = usage_info.get(self.billing_key, {}) - if isinstance(feature_info, dict): - limit = feature_info.get("limit", 0) - usage = feature_info.get("usage", 0) - if limit == -1: - return -1 - return max(0, limit - usage) - return 0 - except Exception: - logger.exception("Failed to get remaining quota for %s, feature %s", tenant_id, self.value) - return -1 diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index ee24424cd5..5bff841c10 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -24,7 +24,7 @@ from models.model import Account, App, AppMode, EndUser from models.workflow import Workflow, WorkflowRun from services.errors.app import QuotaExceededError, WorkflowIdFormatError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError -from services.quota_service import unlimited +from services.quota_service import QuotaService, unlimited from services.workflow_service import WorkflowService from tasks.app_generate.workflow_execute_task import AppExecutionParams, workflow_based_app_execution_task @@ -107,7 +107,7 @@ class AppGenerateService: quota_charge = unlimited() if dify_config.BILLING_ENABLED: try: - quota_charge = QuotaType.WORKFLOW.reserve(app_model.tenant_id) + quota_charge = QuotaService.reserve(QuotaType.WORKFLOW, app_model.tenant_id) except QuotaExceededError: raise InvokeRateLimitError(f"Workflow execution quota limit reached for tenant {app_model.tenant_id}") diff --git a/api/services/async_workflow_service.py b/api/services/async_workflow_service.py index e78e03cce5..327756753c 100644 --- a/api/services/async_workflow_service.py +++ b/api/services/async_workflow_service.py @@ -22,7 +22,7 @@ from models.trigger import WorkflowTriggerLog, WorkflowTriggerLogDict from models.workflow import Workflow from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository from services.errors.app import QuotaExceededError, WorkflowNotFoundError, WorkflowQuotaLimitError -from services.quota_service import unlimited +from services.quota_service import QuotaService, unlimited from services.workflow.entities import AsyncTriggerResponse, TriggerData, WorkflowTaskData from services.workflow.queue_dispatcher import QueueDispatcherManager, QueuePriority from services.workflow_service import WorkflowService @@ -135,7 +135,7 @@ class AsyncWorkflowService: # 7. Reserve quota (commit after successful dispatch) quota_charge = unlimited() try: - quota_charge = QuotaType.WORKFLOW.reserve(trigger_data.tenant_id) + quota_charge = QuotaService.reserve(QuotaType.WORKFLOW, trigger_data.tenant_id) except QuotaExceededError as e: # Update trigger log status trigger_log.status = WorkflowTriggerStatus.RATE_LIMITED diff --git a/api/services/quota_service.py b/api/services/quota_service.py index 77cfdda3a3..4c784315c7 100644 --- a/api/services/quota_service.py +++ b/api/services/quota_service.py @@ -1,9 +1,12 @@ from __future__ import annotations import logging +import uuid from dataclasses import dataclass, field from typing import TYPE_CHECKING +from configs import dify_config + if TYPE_CHECKING: from enums.quota_type import QuotaType @@ -16,7 +19,7 @@ class QuotaCharge: Result of a quota reservation (Reserve phase). Lifecycle: - charge = QuotaType.TRIGGER.consume(tenant_id) # Reserve + charge = QuotaService.consume(QuotaType.TRIGGER, tenant_id) try: do_work() charge.commit() # Confirm consumption @@ -81,10 +84,150 @@ class QuotaCharge: if not self.charge_id or not self._tenant_id or not self._feature_key: return - self._quota_type.release(self.charge_id, self._tenant_id, self._feature_key) + QuotaService.release(self._quota_type, self.charge_id, self._tenant_id, self._feature_key) def unlimited() -> QuotaCharge: from enums.quota_type import QuotaType return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED) + + +class QuotaService: + """Orchestrates quota reserve / commit / release lifecycle via BillingService.""" + + @staticmethod + def consume(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> QuotaCharge: + """ + Reserve + immediate Commit (one-shot mode). + + The returned QuotaCharge supports .refund() which calls Release. + For two-phase usage (e.g. streaming), use reserve() directly. + """ + charge = QuotaService.reserve(quota_type, tenant_id, amount) + if charge.success and charge.charge_id: + charge.commit() + return charge + + @staticmethod + def reserve(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> QuotaCharge: + """ + Reserve quota before task execution (Reserve phase only). + + The caller MUST call charge.commit() after the task succeeds, + or charge.refund() if the task fails. + + Raises: + QuotaExceededError: When quota is insufficient + """ + from services.billing_service import BillingService + from services.errors.app import QuotaExceededError + + if not dify_config.BILLING_ENABLED: + logger.debug("Billing disabled, allowing request for %s", tenant_id) + return QuotaCharge(success=True, charge_id=None, _quota_type=quota_type) + + logger.info("Reserving %d %s quota for tenant %s", amount, quota_type.value, tenant_id) + + if amount <= 0: + raise ValueError("Amount to reserve must be greater than 0") + + request_id = str(uuid.uuid4()) + feature_key = quota_type.billing_key + + try: + reserve_resp = BillingService.quota_reserve( + tenant_id=tenant_id, + feature_key=feature_key, + request_id=request_id, + amount=amount, + ) + + reservation_id = reserve_resp.get("reservation_id") + if not reservation_id: + logger.warning( + "Reserve returned no reservation_id for %s, feature %s, response: %s", + tenant_id, + quota_type.value, + reserve_resp, + ) + raise QuotaExceededError(feature=quota_type.value, tenant_id=tenant_id, required=amount) + + logger.debug( + "Reserved %d %s quota for tenant %s, reservation_id: %s", + amount, + quota_type.value, + tenant_id, + reservation_id, + ) + return QuotaCharge( + success=True, + charge_id=reservation_id, + _quota_type=quota_type, + _tenant_id=tenant_id, + _feature_key=feature_key, + _amount=amount, + ) + + except QuotaExceededError: + raise + except ValueError: + raise + except Exception: + logger.exception("Failed to reserve quota for %s, feature %s", tenant_id, quota_type.value) + return unlimited() + + @staticmethod + def check(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> bool: + if not dify_config.BILLING_ENABLED: + return True + + if amount <= 0: + raise ValueError("Amount to check must be greater than 0") + + try: + remaining = QuotaService.get_remaining(quota_type, tenant_id) + return remaining >= amount if remaining != -1 else True + except Exception: + logger.exception("Failed to check quota for %s, feature %s", tenant_id, quota_type.value) + return True + + @staticmethod + def release(quota_type: QuotaType, reservation_id: str, tenant_id: str, feature_key: str) -> None: + """Release a reservation. Guarantees no exceptions.""" + try: + from services.billing_service import BillingService + + if not dify_config.BILLING_ENABLED: + return + + if not reservation_id: + return + + logger.info("Releasing %s quota, reservation_id: %s", quota_type.value, reservation_id) + BillingService.quota_release( + tenant_id=tenant_id, + feature_key=feature_key, + reservation_id=reservation_id, + ) + except Exception: + logger.exception("Failed to release quota, reservation_id: %s", reservation_id) + + @staticmethod + def get_remaining(quota_type: QuotaType, tenant_id: str) -> int: + from services.billing_service import BillingService + + try: + usage_info = BillingService.get_quota_info(tenant_id) + if isinstance(usage_info, dict): + feature_info = usage_info.get(quota_type.billing_key, {}) + if isinstance(feature_info, dict): + limit = feature_info.get("limit", 0) + usage = feature_info.get("usage", 0) + if limit == -1: + return -1 + return max(0, limit - usage) + return 0 + except Exception: + logger.exception("Failed to get remaining quota for %s, feature %s", tenant_id, quota_type.value) + return -1 diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index 2254ce8e7d..b71ae6fe41 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -28,6 +28,7 @@ from core.workflow.nodes.trigger_webhook.entities import ( WebhookParameter, ) from enums.quota_type import QuotaType +from services.quota_service import QuotaService from extensions.ext_database import db from extensions.ext_redis import redis_client from factories import file_factory @@ -784,7 +785,7 @@ class WebhookService: # reserve quota before triggering workflow execution try: - quota_charge = QuotaType.TRIGGER.reserve(webhook_trigger.tenant_id) + quota_charge = QuotaService.reserve(QuotaType.TRIGGER, webhook_trigger.tenant_id) except QuotaExceededError: AppTriggerService.mark_tenant_triggers_rate_limited(webhook_trigger.tenant_id) logger.info( diff --git a/api/tasks/trigger_processing_tasks.py b/api/tasks/trigger_processing_tasks.py index 1b1e809baf..b9f382eccf 100644 --- a/api/tasks/trigger_processing_tasks.py +++ b/api/tasks/trigger_processing_tasks.py @@ -42,7 +42,7 @@ from models.workflow import Workflow, WorkflowAppLog, WorkflowAppLogCreatedFrom, from services.async_workflow_service import AsyncWorkflowService from services.end_user_service import EndUserService from services.errors.app import QuotaExceededError -from services.quota_service import unlimited +from services.quota_service import QuotaService, unlimited from services.trigger.app_trigger_service import AppTriggerService from services.trigger.trigger_provider_service import TriggerProviderService from services.trigger.trigger_request_service import TriggerHttpRequestCachingService @@ -302,7 +302,7 @@ def dispatch_triggered_workflow( # reserve quota before invoking trigger quota_charge = unlimited() try: - quota_charge = QuotaType.TRIGGER.reserve(subscription.tenant_id) + quota_charge = QuotaService.reserve(QuotaType.TRIGGER, subscription.tenant_id) except QuotaExceededError: AppTriggerService.mark_tenant_triggers_rate_limited(subscription.tenant_id) logger.info( diff --git a/api/tasks/workflow_schedule_tasks.py b/api/tasks/workflow_schedule_tasks.py index 774e1103df..dfb2fb3391 100644 --- a/api/tasks/workflow_schedule_tasks.py +++ b/api/tasks/workflow_schedule_tasks.py @@ -12,7 +12,7 @@ from enums.quota_type import QuotaType from models.trigger import WorkflowSchedulePlan from services.async_workflow_service import AsyncWorkflowService from services.errors.app import QuotaExceededError -from services.quota_service import unlimited +from services.quota_service import QuotaService, unlimited from services.trigger.app_trigger_service import AppTriggerService from services.trigger.schedule_service import ScheduleService from services.workflow.entities import ScheduleTriggerData @@ -44,7 +44,7 @@ def run_schedule_trigger(schedule_id: str) -> None: quota_charge = unlimited() try: - quota_charge = QuotaType.TRIGGER.reserve(schedule.tenant_id) + quota_charge = QuotaService.reserve(QuotaType.TRIGGER, schedule.tenant_id) except QuotaExceededError: AppTriggerService.mark_tenant_triggers_rate_limited(schedule.tenant_id) logger.info("Tenant %s rate limited, skipping schedule trigger %s", schedule.tenant_id, schedule_id) diff --git a/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py b/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py index 7539bae685..3514447240 100644 --- a/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py +++ b/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py @@ -602,9 +602,9 @@ def test_schedule_trigger_creates_trigger_log( ) # Mock quota to avoid rate limiting - from enums import quota_type + from services import quota_service - monkeypatch.setattr(quota_type.QuotaType.TRIGGER, "consume", lambda _tenant_id: quota_type.unlimited()) + monkeypatch.setattr(quota_service.QuotaService, "reserve", lambda *_args, **_kwargs: quota_service.unlimited()) # Execute schedule trigger workflow_schedule_tasks.run_schedule_trigger(plan.id) diff --git a/api/tests/unit_tests/enums/test_quota_type.py b/api/tests/unit_tests/enums/test_quota_type.py index 85b5b8bb3e..f256ff3b4e 100644 --- a/api/tests/unit_tests/enums/test_quota_type.py +++ b/api/tests/unit_tests/enums/test_quota_type.py @@ -1,11 +1,11 @@ -"""Unit tests for QuotaType and QuotaCharge.""" +"""Unit tests for QuotaType, QuotaService, and QuotaCharge.""" from unittest.mock import patch import pytest from enums.quota_type import QuotaType -from services.quota_service import QuotaCharge, unlimited +from services.quota_service import QuotaCharge, QuotaService, unlimited class TestQuotaType: @@ -19,31 +19,33 @@ class TestQuotaType: with pytest.raises(ValueError, match="Invalid quota type"): _ = QuotaType.UNLIMITED.billing_key + +class TestQuotaService: def test_reserve_billing_disabled(self): with ( - patch("configs.dify_config") as mock_cfg, + patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService"), ): mock_cfg.BILLING_ENABLED = False - charge = QuotaType.TRIGGER.reserve("t1") + charge = QuotaService.reserve(QuotaType.TRIGGER, "t1") assert charge.success is True assert charge.charge_id is None def test_reserve_zero_amount_raises(self): - with patch("configs.dify_config") as mock_cfg: + with patch("services.quota_service.dify_config") as mock_cfg: mock_cfg.BILLING_ENABLED = True with pytest.raises(ValueError, match="greater than 0"): - QuotaType.TRIGGER.reserve("t1", amount=0) + QuotaService.reserve(QuotaType.TRIGGER, "t1", amount=0) def test_reserve_success(self): with ( - patch("configs.dify_config") as mock_cfg, + patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): mock_cfg.BILLING_ENABLED = True mock_bs.quota_reserve.return_value = {"reservation_id": "rid-1", "available": 99} - charge = QuotaType.TRIGGER.reserve("t1", amount=1) + charge = QuotaService.reserve(QuotaType.TRIGGER, "t1", amount=1) assert charge.success is True assert charge.charge_id == "rid-1" @@ -56,175 +58,175 @@ class TestQuotaType: from services.errors.app import QuotaExceededError with ( - patch("configs.dify_config") as mock_cfg, + patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): mock_cfg.BILLING_ENABLED = True mock_bs.quota_reserve.return_value = {} with pytest.raises(QuotaExceededError): - QuotaType.TRIGGER.reserve("t1") + QuotaService.reserve(QuotaType.TRIGGER, "t1") def test_reserve_quota_exceeded_propagates(self): from services.errors.app import QuotaExceededError with ( - patch("configs.dify_config") as mock_cfg, + patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): mock_cfg.BILLING_ENABLED = True mock_bs.quota_reserve.side_effect = QuotaExceededError(feature="trigger", tenant_id="t1", required=1) with pytest.raises(QuotaExceededError): - QuotaType.TRIGGER.reserve("t1") + QuotaService.reserve(QuotaType.TRIGGER, "t1") def test_reserve_api_exception_returns_unlimited(self): with ( - patch("configs.dify_config") as mock_cfg, + patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): mock_cfg.BILLING_ENABLED = True mock_bs.quota_reserve.side_effect = RuntimeError("network") - charge = QuotaType.TRIGGER.reserve("t1") + charge = QuotaService.reserve(QuotaType.TRIGGER, "t1") assert charge.success is True assert charge.charge_id is None def test_consume_calls_reserve_and_commit(self): with ( - patch("configs.dify_config") as mock_cfg, + patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): mock_cfg.BILLING_ENABLED = True mock_bs.quota_reserve.return_value = {"reservation_id": "rid-c"} mock_bs.quota_commit.return_value = {} - charge = QuotaType.TRIGGER.consume("t1") + charge = QuotaService.consume(QuotaType.TRIGGER, "t1") assert charge.success is True mock_bs.quota_commit.assert_called_once() def test_check_billing_disabled(self): - with patch("configs.dify_config") as mock_cfg: + with patch("services.quota_service.dify_config") as mock_cfg: mock_cfg.BILLING_ENABLED = False - assert QuotaType.TRIGGER.check("t1") is True + assert QuotaService.check(QuotaType.TRIGGER, "t1") is True def test_check_zero_amount_raises(self): - with patch("configs.dify_config") as mock_cfg: + with patch("services.quota_service.dify_config") as mock_cfg: mock_cfg.BILLING_ENABLED = True with pytest.raises(ValueError, match="greater than 0"): - QuotaType.TRIGGER.check("t1", amount=0) + QuotaService.check(QuotaType.TRIGGER, "t1", amount=0) def test_check_sufficient_quota(self): with ( - patch("configs.dify_config") as mock_cfg, - patch.object(QuotaType.TRIGGER, "get_remaining", return_value=100), + patch("services.quota_service.dify_config") as mock_cfg, + patch.object(QuotaService, "get_remaining", return_value=100), ): mock_cfg.BILLING_ENABLED = True - assert QuotaType.TRIGGER.check("t1", amount=50) is True + assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=50) is True def test_check_insufficient_quota(self): with ( - patch("configs.dify_config") as mock_cfg, - patch.object(QuotaType.TRIGGER, "get_remaining", return_value=5), + patch("services.quota_service.dify_config") as mock_cfg, + patch.object(QuotaService, "get_remaining", return_value=5), ): mock_cfg.BILLING_ENABLED = True - assert QuotaType.TRIGGER.check("t1", amount=10) is False + assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=10) is False def test_check_unlimited_quota(self): with ( - patch("configs.dify_config") as mock_cfg, - patch.object(QuotaType.TRIGGER, "get_remaining", return_value=-1), + patch("services.quota_service.dify_config") as mock_cfg, + patch.object(QuotaService, "get_remaining", return_value=-1), ): mock_cfg.BILLING_ENABLED = True - assert QuotaType.TRIGGER.check("t1", amount=999) is True + assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=999) is True def test_check_exception_returns_true(self): with ( - patch("configs.dify_config") as mock_cfg, - patch.object(QuotaType.TRIGGER, "get_remaining", side_effect=RuntimeError), + patch("services.quota_service.dify_config") as mock_cfg, + patch.object(QuotaService, "get_remaining", side_effect=RuntimeError), ): mock_cfg.BILLING_ENABLED = True - assert QuotaType.TRIGGER.check("t1") is True + assert QuotaService.check(QuotaType.TRIGGER, "t1") is True def test_release_billing_disabled(self): with ( - patch("configs.dify_config") as mock_cfg, + patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): mock_cfg.BILLING_ENABLED = False - QuotaType.TRIGGER.release("rid-1", "t1", "trigger_event") + QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event") mock_bs.quota_release.assert_not_called() def test_release_empty_reservation(self): with ( - patch("configs.dify_config") as mock_cfg, + patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): mock_cfg.BILLING_ENABLED = True - QuotaType.TRIGGER.release("", "t1", "trigger_event") + QuotaService.release(QuotaType.TRIGGER, "", "t1", "trigger_event") mock_bs.quota_release.assert_not_called() def test_release_success(self): with ( - patch("configs.dify_config") as mock_cfg, + patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): mock_cfg.BILLING_ENABLED = True mock_bs.quota_release.return_value = {} - QuotaType.TRIGGER.release("rid-1", "t1", "trigger_event") + QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event") mock_bs.quota_release.assert_called_once_with( tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1" ) def test_release_exception_swallowed(self): with ( - patch("configs.dify_config") as mock_cfg, + patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): mock_cfg.BILLING_ENABLED = True mock_bs.quota_release.side_effect = RuntimeError("fail") - QuotaType.TRIGGER.release("rid-1", "t1", "trigger_event") + QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event") def test_get_remaining_normal(self): with patch("services.billing_service.BillingService") as mock_bs: mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": 100, "usage": 30}} - assert QuotaType.TRIGGER.get_remaining("t1") == 70 + assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 70 def test_get_remaining_unlimited(self): with patch("services.billing_service.BillingService") as mock_bs: mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": -1, "usage": 0}} - assert QuotaType.TRIGGER.get_remaining("t1") == -1 + assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == -1 def test_get_remaining_over_limit_returns_zero(self): with patch("services.billing_service.BillingService") as mock_bs: mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": 10, "usage": 15}} - assert QuotaType.TRIGGER.get_remaining("t1") == 0 + assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0 def test_get_remaining_exception_returns_neg1(self): with patch("services.billing_service.BillingService") as mock_bs: mock_bs.get_quota_info.side_effect = RuntimeError - assert QuotaType.TRIGGER.get_remaining("t1") == -1 + assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == -1 def test_get_remaining_empty_response(self): with patch("services.billing_service.BillingService") as mock_bs: mock_bs.get_quota_info.return_value = {} - assert QuotaType.TRIGGER.get_remaining("t1") == 0 + assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0 def test_get_remaining_non_dict_response(self): with patch("services.billing_service.BillingService") as mock_bs: mock_bs.get_quota_info.return_value = "invalid" - assert QuotaType.TRIGGER.get_remaining("t1") == 0 + assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0 def test_get_remaining_feature_not_in_response(self): with patch("services.billing_service.BillingService") as mock_bs: mock_bs.get_quota_info.return_value = {"other_feature": {"limit": 100, "usage": 0}} - remaining = QuotaType.TRIGGER.get_remaining("t1") + remaining = QuotaService.get_remaining(QuotaType.TRIGGER, "t1") assert remaining == 0 def test_get_remaining_non_dict_feature_info(self): with patch("services.billing_service.BillingService") as mock_bs: mock_bs.get_quota_info.return_value = {"trigger_event": "not_a_dict"} - assert QuotaType.TRIGGER.get_remaining("t1") == 0 + assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0 class TestQuotaCharge: @@ -310,7 +312,7 @@ class TestQuotaCharge: charge.commit() def test_refund_success(self): - with patch.object(QuotaType.TRIGGER, "release") as mock_rel: + with patch.object(QuotaService, "release") as mock_rel: charge = QuotaCharge( success=True, charge_id="rid-1", @@ -319,16 +321,16 @@ class TestQuotaCharge: _feature_key="trigger_event", ) charge.refund() - mock_rel.assert_called_once_with("rid-1", "t1", "trigger_event") + mock_rel.assert_called_once_with(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event") def test_refund_no_charge_id_noop(self): - with patch.object(QuotaType.TRIGGER, "release") as mock_rel: + with patch.object(QuotaService, "release") as mock_rel: charge = QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.TRIGGER) charge.refund() mock_rel.assert_not_called() def test_refund_no_tenant_id_noop(self): - with patch.object(QuotaType.TRIGGER, "release") as mock_rel: + with patch.object(QuotaService, "release") as mock_rel: charge = QuotaCharge( success=True, charge_id="rid-1", diff --git a/api/tests/unit_tests/services/test_app_generate_service.py b/api/tests/unit_tests/services/test_app_generate_service.py index 68ee6ae9d6..6750dca570 100644 --- a/api/tests/unit_tests/services/test_app_generate_service.py +++ b/api/tests/unit_tests/services/test_app_generate_service.py @@ -448,7 +448,7 @@ class TestGenerateBilling: monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) quota_charge = MagicMock() reserve_mock = mocker.patch( - "services.app_generate_service.QuotaType.WORKFLOW.reserve", + "services.app_generate_service.QuotaService.reserve", return_value=quota_charge, ) mocker.patch( @@ -476,7 +476,7 @@ class TestGenerateBilling: monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) mocker.patch( - "services.app_generate_service.QuotaType.WORKFLOW.reserve", + "services.app_generate_service.QuotaService.reserve", side_effect=QuotaExceededError(feature="workflow", tenant_id="t", required=1), ) @@ -493,7 +493,7 @@ class TestGenerateBilling: monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) quota_charge = MagicMock() mocker.patch( - "services.app_generate_service.QuotaType.WORKFLOW.reserve", + "services.app_generate_service.QuotaService.reserve", return_value=quota_charge, ) mocker.patch( diff --git a/api/tests/unit_tests/services/test_async_workflow_service.py b/api/tests/unit_tests/services/test_async_workflow_service.py index ab83c8020f..07f8324d13 100644 --- a/api/tests/unit_tests/services/test_async_workflow_service.py +++ b/api/tests/unit_tests/services/test_async_workflow_service.py @@ -57,7 +57,7 @@ class TestAsyncWorkflowService: - repo: SQLAlchemyWorkflowTriggerLogRepository - dispatcher_manager_class: QueueDispatcherManager class - dispatcher: dispatcher instance - - quota_workflow: QuotaType.WORKFLOW + - quota_service: QuotaService mock - get_workflow: AsyncWorkflowService._get_workflow method - professional_task: execute_workflow_professional - team_task: execute_workflow_team @@ -72,7 +72,7 @@ class TestAsyncWorkflowService: mock_repo.create.side_effect = _create_side_effect mock_dispatcher = MagicMock() - quota_workflow = MagicMock() + mock_quota_service = MagicMock() mock_get_workflow = MagicMock() mock_professional_task = MagicMock() @@ -93,8 +93,8 @@ class TestAsyncWorkflowService: ) as mock_get_workflow, patch.object( async_workflow_service_module, - "QuotaType", - new=SimpleNamespace(WORKFLOW=quota_workflow), + "QuotaService", + new=mock_quota_service, ), patch.object(async_workflow_service_module, "execute_workflow_professional") as mock_professional_task, patch.object(async_workflow_service_module, "execute_workflow_team") as mock_team_task, @@ -107,7 +107,7 @@ class TestAsyncWorkflowService: "repo": mock_repo, "dispatcher_manager_class": mock_dispatcher_manager_class, "dispatcher": mock_dispatcher, - "quota_workflow": quota_workflow, + "quota_service": mock_quota_service, "get_workflow": mock_get_workflow, "professional_task": mock_professional_task, "team_task": mock_team_task, @@ -147,7 +147,7 @@ class TestAsyncWorkflowService: mocks["sandbox_task"].delay.return_value = task_result quota_charge_mock = MagicMock() - mocks["quota_workflow"].reserve.return_value = quota_charge_mock + mocks["quota_service"].reserve.return_value = quota_charge_mock class DummyAccount: def __init__(self, user_id: str): @@ -166,7 +166,7 @@ class TestAsyncWorkflowService: assert result.status == "queued" assert result.queue == queue_name - mocks["quota_workflow"].reserve.assert_called_once_with("tenant-123") + mocks["quota_service"].reserve.assert_called_once() quota_charge_mock.commit.assert_called_once() assert session.commit.call_count == 2 @@ -254,7 +254,7 @@ class TestAsyncWorkflowService: mocks = async_workflow_trigger_mocks mocks["dispatcher"].get_queue_name.return_value = QueuePriority.TEAM mocks["get_workflow"].return_value = workflow - mocks["quota_workflow"].reserve.side_effect = QuotaExceededError( + mocks["quota_service"].reserve.side_effect = QuotaExceededError( feature="workflow", tenant_id="tenant-123", required=1, From 58241a89a557c45cd94b5698a189aa9d3d4269c0 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Tue, 31 Mar 2026 14:59:54 +0800 Subject: [PATCH 009/103] fix linter --- api/services/trigger/webhook_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index b71ae6fe41..844dddfb65 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -28,7 +28,6 @@ from core.workflow.nodes.trigger_webhook.entities import ( WebhookParameter, ) from enums.quota_type import QuotaType -from services.quota_service import QuotaService from extensions.ext_database import db from extensions.ext_redis import redis_client from factories import file_factory @@ -39,6 +38,7 @@ from models.workflow import Workflow from services.async_workflow_service import AsyncWorkflowService from services.end_user_service import EndUserService from services.errors.app import QuotaExceededError +from services.quota_service import QuotaService from services.trigger.app_trigger_service import AppTriggerService from services.workflow.entities import WebhookTriggerData From f319a9e42f89d0b8bd3f78ec8e5f1c5ce13e6dbe Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Tue, 31 Mar 2026 15:22:43 +0800 Subject: [PATCH 010/103] fix test case --- .../services/test_app_generate_service.py | 5 +++++ api/tests/unit_tests/services/test_app_generate_service.py | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py index eea9673710..3229693fd4 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py @@ -36,6 +36,7 @@ class TestAppGenerateService: ) as mock_message_based_generator, patch("services.account_service.FeatureService", autospec=True) as mock_account_feature_service, patch("services.app_generate_service.dify_config", autospec=True) as mock_dify_config, + patch("services.quota_service.dify_config", autospec=True) as mock_quota_dify_config, patch("configs.dify_config", autospec=True) as mock_global_dify_config, ): # Setup default mock returns for billing service @@ -107,6 +108,8 @@ class TestAppGenerateService: mock_dify_config.APP_DEFAULT_ACTIVE_REQUESTS = 100 mock_dify_config.APP_DAILY_RATE_LIMIT = 1000 + mock_quota_dify_config.BILLING_ENABLED = False + mock_global_dify_config.BILLING_ENABLED = False mock_global_dify_config.APP_MAX_ACTIVE_REQUESTS = 100 mock_global_dify_config.APP_DAILY_RATE_LIMIT = 1000 @@ -124,6 +127,7 @@ class TestAppGenerateService: "message_based_generator": mock_message_based_generator, "account_feature_service": mock_account_feature_service, "dify_config": mock_dify_config, + "quota_dify_config": mock_quota_dify_config, "global_dify_config": mock_global_dify_config, } @@ -471,6 +475,7 @@ class TestAppGenerateService: # Set BILLING_ENABLED to True for this test mock_external_service_dependencies["dify_config"].BILLING_ENABLED = True + mock_external_service_dependencies["quota_dify_config"].BILLING_ENABLED = True mock_external_service_dependencies["global_dify_config"].BILLING_ENABLED = True # Setup test arguments diff --git a/api/tests/unit_tests/services/test_app_generate_service.py b/api/tests/unit_tests/services/test_app_generate_service.py index 6750dca570..cb258fea0a 100644 --- a/api/tests/unit_tests/services/test_app_generate_service.py +++ b/api/tests/unit_tests/services/test_app_generate_service.py @@ -25,6 +25,7 @@ import services.app_generate_service as ags_module from core.app.entities.app_invoke_entities import InvokeFrom from models.model import AppMode from services.app_generate_service import AppGenerateService +from enums.quota_type import QuotaType from services.errors.app import WorkflowIdFormatError, WorkflowNotFoundError @@ -467,7 +468,7 @@ class TestGenerateBilling: invoke_from=InvokeFrom.SERVICE_API, streaming=False, ) - reserve_mock.assert_called_once_with("tenant-id") + reserve_mock.assert_called_once_with(QuotaType.WORKFLOW, "tenant-id") quota_charge.commit.assert_called_once() def test_billing_quota_exceeded_raises_rate_limit_error(self, mocker, monkeypatch): From c543188434c39cf00684ad80a47acd268c08c463 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Tue, 31 Mar 2026 15:22:51 +0800 Subject: [PATCH 011/103] fix linter --- api/tests/unit_tests/services/test_app_generate_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit_tests/services/test_app_generate_service.py b/api/tests/unit_tests/services/test_app_generate_service.py index cb258fea0a..c88daf6b1e 100644 --- a/api/tests/unit_tests/services/test_app_generate_service.py +++ b/api/tests/unit_tests/services/test_app_generate_service.py @@ -23,9 +23,9 @@ import pytest import services.app_generate_service as ags_module from core.app.entities.app_invoke_entities import InvokeFrom +from enums.quota_type import QuotaType from models.model import AppMode from services.app_generate_service import AppGenerateService -from enums.quota_type import QuotaType from services.errors.app import WorkflowIdFormatError, WorkflowNotFoundError From 4653ed7ead91c7cf82dfaf398f3cba8db62ab3e6 Mon Sep 17 00:00:00 2001 From: hj24 Date: Tue, 31 Mar 2026 18:23:32 +0800 Subject: [PATCH 012/103] refactor: enhance billing info response handling --- api/services/billing_service.py | 56 +++++- api/services/feature_service.py | 5 +- .../services/test_billing_service.py | 183 ++++++++++++++++-- 3 files changed, 225 insertions(+), 19 deletions(-) diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 70d4ce1ee6..13f9d1fcf4 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -2,7 +2,7 @@ import json import logging import os from collections.abc import Sequence -from typing import Literal +from typing import Literal, NotRequired import httpx from pydantic import TypeAdapter @@ -26,6 +26,56 @@ class SubscriptionPlan(TypedDict): expiration_date: int +class _BillingQuota(TypedDict): + size: int + limit: int + + +class _VectorSpaceQuota(TypedDict): + size: float + limit: int + + +class _KnowledgeRateLimit(TypedDict): + size: NotRequired[int] + limit: int + + +class _BillingSubscription(TypedDict): + plan: str + interval: str + education: bool + + +class BillingInfo(TypedDict): + """Response of /subscription/info. + + NOTE (hj24): + - Fields not listed here (e.g. trigger_event, api_rate_limit) are stripped by TypeAdapter.validate_python() + - To ensure the precision, billing may convert fields like int as str, be careful when use TypeAdapter: + 1. validate_python in non-strict mode will coerce it to the expected type + 2. In strict mode, it will raise ValidationError + 3. To preserve compatibility, always keep non-strict mode here and avoid strict mode + """ + + enabled: bool + subscription: _BillingSubscription + members: _BillingQuota + apps: _BillingQuota + vector_space: _VectorSpaceQuota + knowledge_rate_limit: _KnowledgeRateLimit + documents_upload_quota: _BillingQuota + annotation_quota_limit: _BillingQuota + docs_processing: str + can_replace_logo: bool + model_load_balancing_enabled: bool + knowledge_pipeline_publish_enabled: bool + next_credit_reset_date: NotRequired[int] + + +_billing_info_adapter = TypeAdapter(BillingInfo) + + class BillingService: base_url = os.environ.get("BILLING_API_URL", "BILLING_API_URL") secret_key = os.environ.get("BILLING_API_SECRET_KEY", "BILLING_API_SECRET_KEY") @@ -38,11 +88,11 @@ class BillingService: _PLAN_CACHE_TTL = 600 @classmethod - def get_info(cls, tenant_id: str): + def get_info(cls, tenant_id: str) -> BillingInfo: params = {"tenant_id": tenant_id} billing_info = cls._send_request("GET", "/subscription/info", params=params) - return billing_info + return _billing_info_adapter.validate_python(billing_info) @classmethod def get_tenant_feature_plan_usage_info(cls, tenant_id: str): diff --git a/api/services/feature_service.py b/api/services/feature_service.py index f38e1762d1..56d896629c 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -312,7 +312,10 @@ class FeatureService: features.apps.limit = billing_info["apps"]["limit"] if "vector_space" in billing_info: - features.vector_space.size = billing_info["vector_space"]["size"] + # NOTE (hj24): billing API returns vector_space.size as float (e.g. 0.0) + # but LimitationModel.size is int; truncate here for compatibility + features.vector_space.size = int(billing_info["vector_space"]["size"]) + # NOTE END features.vector_space.limit = billing_info["vector_space"]["limit"] if "documents_upload_quota" in billing_info: diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index 316381f0ca..4eb35e50b7 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -290,9 +290,19 @@ class TestBillingServiceSubscriptionInfo: # Arrange tenant_id = "tenant-123" expected_response = { - "subscription_plan": "professional", - "billing_cycle": "monthly", - "status": "active", + "enabled": True, + "subscription": {"plan": "professional", "interval": "month", "education": False}, + "members": {"size": 1, "limit": 50}, + "apps": {"size": 1, "limit": 200}, + "vector_space": {"size": 0.0, "limit": 20480}, + "knowledge_rate_limit": {"limit": 1000}, + "documents_upload_quota": {"size": 0, "limit": 1000}, + "annotation_quota_limit": {"size": 0, "limit": 5000}, + "docs_processing": "top-priority", + "can_replace_logo": True, + "model_load_balancing_enabled": True, + "knowledge_pipeline_publish_enabled": True, + "next_credit_reset_date": 1775952000, } mock_send_request.return_value = expected_response @@ -1009,17 +1019,14 @@ class TestBillingServiceEdgeCases: yield mock def test_get_info_empty_response(self, mock_send_request): - """Test handling of empty billing info response.""" - # Arrange + """Empty response from billing API should raise ValidationError due to missing required fields.""" + from pydantic import ValidationError + tenant_id = "tenant-empty" mock_send_request.return_value = {} - # Act - result = BillingService.get_info(tenant_id) - - # Assert - assert result == {} - mock_send_request.assert_called_once() + with pytest.raises(ValidationError): + BillingService.get_info(tenant_id) def test_update_tenant_feature_plan_usage_zero_delta(self, mock_send_request): """Test updating tenant feature usage with zero delta (no change).""" @@ -1434,12 +1441,21 @@ class TestBillingServiceIntegrationScenarios: # Step 1: Get current billing info mock_send_request.return_value = { - "subscription_plan": "sandbox", - "billing_cycle": "monthly", - "status": "active", + "enabled": True, + "subscription": {"plan": "sandbox", "interval": "", "education": False}, + "members": {"size": 0, "limit": 1}, + "apps": {"size": 0, "limit": 5}, + "vector_space": {"size": 0.0, "limit": 50}, + "knowledge_rate_limit": {"limit": 10}, + "documents_upload_quota": {"size": 0, "limit": 50}, + "annotation_quota_limit": {"size": 0, "limit": 10}, + "docs_processing": "standard", + "can_replace_logo": False, + "model_load_balancing_enabled": False, + "knowledge_pipeline_publish_enabled": False, } current_info = BillingService.get_info(tenant_id) - assert current_info["subscription_plan"] == "sandbox" + assert current_info["subscription"]["plan"] == "sandbox" # Step 2: Get payment link for upgrade mock_send_request.return_value = {"payment_link": "https://payment.example.com/upgrade"} @@ -1553,3 +1569,140 @@ class TestBillingServiceIntegrationScenarios: mock_send_request.return_value = {"result": "success", "activated": True} activate_result = BillingService.EducationIdentity.activate(account, "token-123", "MIT", "student") assert activate_result["activated"] is True + + +class TestBillingServiceSubscriptionInfoDataType: + """Unit tests for data type coercion in BillingService.get_info + + 1. Verifies the get_info returns correct Python types for numeric fields + 2. Ensure the compatibility regardless of what results the upstream billing API returns + """ + + @pytest.fixture + def mock_send_request(self): + with patch.object(BillingService, "_send_request") as mock: + yield mock + + @pytest.fixture + def normal_billing_response(self) -> dict: + return { + "enabled": True, + "subscription": { + "plan": "team", + "interval": "year", + "education": False, + }, + "members": {"size": 10, "limit": 50}, + "apps": {"size": 80, "limit": 200}, + "vector_space": {"size": 5120.75, "limit": 20480}, + "knowledge_rate_limit": {"limit": 1000}, + "documents_upload_quota": {"size": 450, "limit": 1000}, + "annotation_quota_limit": {"size": 1200, "limit": 5000}, + "docs_processing": "top-priority", + "can_replace_logo": True, + "model_load_balancing_enabled": True, + "knowledge_pipeline_publish_enabled": True, + "next_credit_reset_date": 1745971200, + } + + @pytest.fixture + def string_billing_response(self) -> dict: + return { + "enabled": True, + "subscription": { + "plan": "team", + "interval": "year", + "education": False, + }, + "members": {"size": "10", "limit": "50"}, + "apps": {"size": "80", "limit": "200"}, + "vector_space": {"size": "5120.75", "limit": "20480"}, + "knowledge_rate_limit": {"limit": "1000"}, + "documents_upload_quota": {"size": "450", "limit": "1000"}, + "annotation_quota_limit": {"size": "1200", "limit": "5000"}, + "docs_processing": "top-priority", + "can_replace_logo": True, + "model_load_balancing_enabled": True, + "knowledge_pipeline_publish_enabled": True, + "next_credit_reset_date": "1745971200", + } + + @staticmethod + def _assert_billing_info_types(result: dict): + assert isinstance(result["enabled"], bool) + assert isinstance(result["subscription"]["plan"], str) + assert isinstance(result["subscription"]["interval"], str) + assert isinstance(result["subscription"]["education"], bool) + + assert isinstance(result["members"]["size"], int) + assert isinstance(result["members"]["limit"], int) + + assert isinstance(result["apps"]["size"], int) + assert isinstance(result["apps"]["limit"], int) + + assert isinstance(result["vector_space"]["size"], float) + assert isinstance(result["vector_space"]["limit"], int) + + assert isinstance(result["knowledge_rate_limit"]["limit"], int) + + assert isinstance(result["documents_upload_quota"]["size"], int) + assert isinstance(result["documents_upload_quota"]["limit"], int) + + assert isinstance(result["annotation_quota_limit"]["size"], int) + assert isinstance(result["annotation_quota_limit"]["limit"], int) + + assert isinstance(result["docs_processing"], str) + assert isinstance(result["can_replace_logo"], bool) + assert isinstance(result["model_load_balancing_enabled"], bool) + assert isinstance(result["knowledge_pipeline_publish_enabled"], bool) + if "next_credit_reset_date" in result: + assert isinstance(result["next_credit_reset_date"], int) + + def test_get_info_with_normal_types(self, mock_send_request, normal_billing_response): + """When the billing API returns native numeric types, get_info should preserve them.""" + mock_send_request.return_value = normal_billing_response + + result = BillingService.get_info("tenant-type-test") + + self._assert_billing_info_types(result) + mock_send_request.assert_called_once_with("GET", "/subscription/info", params={"tenant_id": "tenant-type-test"}) + + def test_get_info_with_string_types(self, mock_send_request, string_billing_response): + """When the billing API returns numeric values as strings, get_info should coerce them.""" + mock_send_request.return_value = string_billing_response + + result = BillingService.get_info("tenant-type-test") + + self._assert_billing_info_types(result) + mock_send_request.assert_called_once_with("GET", "/subscription/info", params={"tenant_id": "tenant-type-test"}) + + def test_get_info_without_optional_fields(self, mock_send_request, string_billing_response): + """NotRequired fields can be absent without raising.""" + del string_billing_response["next_credit_reset_date"] + mock_send_request.return_value = string_billing_response + + result = BillingService.get_info("tenant-type-test") + + assert "next_credit_reset_date" not in result + self._assert_billing_info_types(result) + + def test_get_info_with_extra_fields(self, mock_send_request, string_billing_response): + """Undefined fields are silently stripped by validate_python.""" + string_billing_response["new_feature"] = "something" + mock_send_request.return_value = string_billing_response + + result = BillingService.get_info("tenant-type-test") + + # extra fields are dropped by TypeAdapter on TypedDict + assert "new_feature" not in result + self._assert_billing_info_types(result) + + def test_get_info_missing_required_field_raises(self, mock_send_request, string_billing_response): + """Missing a required field should raise ValidationError.""" + from pydantic import ValidationError + + del string_billing_response["members"] + mock_send_request.return_value = string_billing_response + + with pytest.raises(ValidationError): + BillingService.get_info("tenant-type-test") From 919c08045272f909353f4599eba64d5f02be8014 Mon Sep 17 00:00:00 2001 From: hj24 Date: Wed, 1 Apr 2026 10:35:34 +0800 Subject: [PATCH 013/103] chore: update comments --- api/services/billing_service.py | 4 ++++ api/services/feature_service.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 13f9d1fcf4..0f0c49e7fe 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -37,7 +37,11 @@ class _VectorSpaceQuota(TypedDict): class _KnowledgeRateLimit(TypedDict): + # NOTE (hj24): + # 1. Return for sandbox users but is null for other plans, it's defined but never used. + # 2. Keep it for compatibility for now, can be deprecated in future versions. size: NotRequired[int] + # NOTE END limit: int diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 56d896629c..df653e0ba7 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -336,7 +336,11 @@ class FeatureService: features.model_load_balancing_enabled = billing_info["model_load_balancing_enabled"] if "knowledge_rate_limit" in billing_info: + # NOTE (hj24): + # 1. knowledge_rate_limit size is nullable, currently it's defined but never used, only limit is used. + # 2. So be careful if later we decide to use [size], we cannot assume it is always present. features.knowledge_rate_limit = billing_info["knowledge_rate_limit"]["limit"] + # NOTE END if "knowledge_pipeline_publish_enabled" in billing_info: features.knowledge_pipeline.publish_enabled = billing_info["knowledge_pipeline_publish_enabled"] From b3870524d4c9c5fdba7836939ebd8eab2e93163e Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Thu, 2 Apr 2026 09:52:52 +0800 Subject: [PATCH 014/103] fix usage get --- api/services/feature_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/services/feature_service.py b/api/services/feature_service.py index f38e1762d1..9a2060894a 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -281,7 +281,7 @@ class FeatureService: def _fulfill_params_from_billing_api(cls, features: FeatureModel, tenant_id: str): billing_info = BillingService.get_info(tenant_id) - features_usage_info = BillingService.get_tenant_feature_plan_usage_info(tenant_id) + features_usage_info = BillingService.get_quota_info(tenant_id) features.billing.enabled = billing_info["enabled"] features.billing.subscription.plan = billing_info["subscription"]["plan"] From 20ddc9c48a4ebd2395f228e52ee8e9bff24bfb22 Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 2 Apr 2026 11:22:46 +0800 Subject: [PATCH 015/103] fix: url query change record cookie --- .../__tests__/cookie-recorder.spec.tsx | 18 ++++++++++++++++++ .../billing/partner-stack/cookie-recorder.tsx | 4 ++-- .../components/billing/partner-stack/index.tsx | 4 ++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx b/web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx index 1441653c9c..8c1639e941 100644 --- a/web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx +++ b/web/app/components/billing/partner-stack/__tests__/cookie-recorder.spec.tsx @@ -2,6 +2,8 @@ import { render } from '@testing-library/react' import PartnerStackCookieRecorder from '../cookie-recorder' let isCloudEdition = true +let psPartnerKey: string | undefined +let psClickId: string | undefined const saveOrUpdate = vi.fn() @@ -13,6 +15,8 @@ vi.mock('@/config', () => ({ vi.mock('../use-ps-info', () => ({ default: () => ({ + psPartnerKey, + psClickId, saveOrUpdate, }), })) @@ -21,6 +25,8 @@ describe('PartnerStackCookieRecorder', () => { beforeEach(() => { vi.clearAllMocks() isCloudEdition = true + psPartnerKey = undefined + psClickId = undefined }) it('should call saveOrUpdate once on mount when running in cloud edition', () => { @@ -42,4 +48,16 @@ describe('PartnerStackCookieRecorder', () => { expect(container.innerHTML).toBe('') }) + + it('should call saveOrUpdate again when partner stack query changes', () => { + const { rerender } = render() + + expect(saveOrUpdate).toHaveBeenCalledTimes(1) + + psPartnerKey = 'updated-partner' + psClickId = 'updated-click' + rerender() + + expect(saveOrUpdate).toHaveBeenCalledTimes(2) + }) }) diff --git a/web/app/components/billing/partner-stack/cookie-recorder.tsx b/web/app/components/billing/partner-stack/cookie-recorder.tsx index 3c75b2973c..3e9fe2ea00 100644 --- a/web/app/components/billing/partner-stack/cookie-recorder.tsx +++ b/web/app/components/billing/partner-stack/cookie-recorder.tsx @@ -5,13 +5,13 @@ import { IS_CLOUD_EDITION } from '@/config' import usePSInfo from './use-ps-info' const PartnerStackCookieRecorder = () => { - const { saveOrUpdate } = usePSInfo() + const { psPartnerKey, psClickId, saveOrUpdate } = usePSInfo() useEffect(() => { if (!IS_CLOUD_EDITION) return saveOrUpdate() - }, []) + }, [psPartnerKey, psClickId, saveOrUpdate]) return null } diff --git a/web/app/components/billing/partner-stack/index.tsx b/web/app/components/billing/partner-stack/index.tsx index e7b954a576..be77f0967b 100644 --- a/web/app/components/billing/partner-stack/index.tsx +++ b/web/app/components/billing/partner-stack/index.tsx @@ -6,7 +6,7 @@ import { IS_CLOUD_EDITION } from '@/config' import usePSInfo from './use-ps-info' const PartnerStack: FC = () => { - const { saveOrUpdate, bind } = usePSInfo() + const { psPartnerKey, psClickId, saveOrUpdate, bind } = usePSInfo() useEffect(() => { if (!IS_CLOUD_EDITION) return @@ -14,7 +14,7 @@ const PartnerStack: FC = () => { saveOrUpdate() // bind PartnerStack info after user logged in bind() - }, []) + }, [psPartnerKey, psClickId, saveOrUpdate, bind]) return null } From 359007848d3b5f93e7ee0e44322678e600fa8536 Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 2 Apr 2026 12:53:07 +0800 Subject: [PATCH 016/103] chore: remove save binded cookie --- web/app/components/billing/partner-stack/use-ps-info.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/app/components/billing/partner-stack/use-ps-info.ts b/web/app/components/billing/partner-stack/use-ps-info.ts index 5a83dec0e5..3ad9b392bc 100644 --- a/web/app/components/billing/partner-stack/use-ps-info.ts +++ b/web/app/components/billing/partner-stack/use-ps-info.ts @@ -27,6 +27,8 @@ const usePSInfo = () => { const domain = globalThis.location?.hostname.replace('cloud', '') const saveOrUpdate = useCallback(() => { + if (hasBind) + return if (!psPartnerKey || !psClickId) return if (!isPSChanged) @@ -39,7 +41,7 @@ const usePSInfo = () => { path: '/', domain, }) - }, [psPartnerKey, psClickId, isPSChanged, domain]) + }, [psPartnerKey, psClickId, isPSChanged, domain, hasBind]) const bind = useCallback(async () => { if (psPartnerKey && psClickId && !hasBind) { From 4ba8c71962821c65813d59a5b71525fbe86636f8 Mon Sep 17 00:00:00 2001 From: hj24 Date: Thu, 2 Apr 2026 17:17:40 +0800 Subject: [PATCH 017/103] feat: debug partnerstack --- api/controllers/console/billing/billing.py | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/api/controllers/console/billing/billing.py b/api/controllers/console/billing/billing.py index 23c01eedb1..f5e0c01fe2 100644 --- a/api/controllers/console/billing/billing.py +++ b/api/controllers/console/billing/billing.py @@ -1,4 +1,6 @@ import base64 +import json +from datetime import UTC, datetime, timedelta from typing import Literal from flask import request @@ -9,6 +11,7 @@ from werkzeug.exceptions import BadRequest from controllers.console import console_ns from controllers.console.wraps import account_initialization_required, only_edition_cloud, setup_required from enums.cloud_plan import CloudPlan +from extensions.ext_redis import redis_client from libs.login import current_account_with_tenant, login_required from services.billing_service import BillingService @@ -84,3 +87,35 @@ class PartnerTenants(Resource): raise BadRequest("Invalid partner information") return BillingService.sync_partner_tenants_bindings(current_user.id, decoded_partner_key, click_id) + + +_DEBUG_KEY = "billing:debug" +_DEBUG_TTL = timedelta(days=7) + + +class DebugDataPayload(BaseModel): + type: str = Field(..., min_length=1, description="Data type key") + data: str = Field(..., min_length=1, description="Data value to append") + + +@console_ns.route("/billing/debug/data") +class DebugData(Resource): + def post(self): + body = DebugDataPayload.model_validate(request.get_json(force=True)) + item = json.dumps({ + "type": body.type, + "data": body.data, + "createTime": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + }) + redis_client.lpush(_DEBUG_KEY, item) + redis_client.expire(_DEBUG_KEY, _DEBUG_TTL) + return {"result": "ok"}, 201 + + def get(self): + recent = request.args.get("recent", 10, type=int) + items = redis_client.lrange(_DEBUG_KEY, 0, recent - 1) + return { + "data": [ + json.loads(item.decode("utf-8") if isinstance(item, bytes) else item) for item in items + ] + } From 1fdb653875f7d70342bbefad286d332334efa5cd Mon Sep 17 00:00:00 2001 From: hj24 Date: Thu, 2 Apr 2026 17:17:40 +0800 Subject: [PATCH 018/103] feat: debug partnerstack --- api/controllers/console/billing/billing.py | 35 ++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/api/controllers/console/billing/billing.py b/api/controllers/console/billing/billing.py index 23c01eedb1..f5e0c01fe2 100644 --- a/api/controllers/console/billing/billing.py +++ b/api/controllers/console/billing/billing.py @@ -1,4 +1,6 @@ import base64 +import json +from datetime import UTC, datetime, timedelta from typing import Literal from flask import request @@ -9,6 +11,7 @@ from werkzeug.exceptions import BadRequest from controllers.console import console_ns from controllers.console.wraps import account_initialization_required, only_edition_cloud, setup_required from enums.cloud_plan import CloudPlan +from extensions.ext_redis import redis_client from libs.login import current_account_with_tenant, login_required from services.billing_service import BillingService @@ -84,3 +87,35 @@ class PartnerTenants(Resource): raise BadRequest("Invalid partner information") return BillingService.sync_partner_tenants_bindings(current_user.id, decoded_partner_key, click_id) + + +_DEBUG_KEY = "billing:debug" +_DEBUG_TTL = timedelta(days=7) + + +class DebugDataPayload(BaseModel): + type: str = Field(..., min_length=1, description="Data type key") + data: str = Field(..., min_length=1, description="Data value to append") + + +@console_ns.route("/billing/debug/data") +class DebugData(Resource): + def post(self): + body = DebugDataPayload.model_validate(request.get_json(force=True)) + item = json.dumps({ + "type": body.type, + "data": body.data, + "createTime": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + }) + redis_client.lpush(_DEBUG_KEY, item) + redis_client.expire(_DEBUG_KEY, _DEBUG_TTL) + return {"result": "ok"}, 201 + + def get(self): + recent = request.args.get("recent", 10, type=int) + items = redis_client.lrange(_DEBUG_KEY, 0, recent - 1) + return { + "data": [ + json.loads(item.decode("utf-8") if isinstance(item, bytes) else item) for item in items + ] + } From 12a0f85b720b549255d3c8e4984278d5375bc28a Mon Sep 17 00:00:00 2001 From: hj24 Date: Thu, 2 Apr 2026 17:52:55 +0800 Subject: [PATCH 019/103] feat: clear api --- api/controllers/console/billing/billing.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/controllers/console/billing/billing.py b/api/controllers/console/billing/billing.py index f5e0c01fe2..51ddf1b292 100644 --- a/api/controllers/console/billing/billing.py +++ b/api/controllers/console/billing/billing.py @@ -119,3 +119,7 @@ class DebugData(Resource): json.loads(item.decode("utf-8") if isinstance(item, bytes) else item) for item in items ] } + + def delete(self): + redis_client.delete(_DEBUG_KEY) + return {"result": "ok"} From 65e434cf0678e19348f80957ac53c320fe7efad8 Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 2 Apr 2026 17:53:52 +0800 Subject: [PATCH 020/103] chore: add debug --- .../components/billing/partner-stack/use-ps-info.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/web/app/components/billing/partner-stack/use-ps-info.ts b/web/app/components/billing/partner-stack/use-ps-info.ts index 3ad9b392bc..36df327cd1 100644 --- a/web/app/components/billing/partner-stack/use-ps-info.ts +++ b/web/app/components/billing/partner-stack/use-ps-info.ts @@ -44,6 +44,18 @@ const usePSInfo = () => { }, [psPartnerKey, psClickId, isPSChanged, domain, hasBind]) const bind = useCallback(async () => { + // for debug + if (!hasBind) + fetch("https://cloud.dify.dev/console/api/billing/debug/data", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + type: "bind", + data: psPartnerKey ? JSON.stringify({ psPartnerKey, psClickId }) : "", + }), + }) if (psPartnerKey && psClickId && !hasBind) { let shouldRemoveCookie = false try { From b2861e019ba5409c99d00971224b1fd111f952aa Mon Sep 17 00:00:00 2001 From: hj24 Date: Thu, 2 Apr 2026 18:16:31 +0800 Subject: [PATCH 021/103] fix: merge error --- api/services/billing_service.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 8899d2e870..a183f09370 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -2,11 +2,7 @@ import json import logging import os from collections.abc import Sequence -<<<<<<< HEAD -from typing import Literal, NotRequired -======= -from typing import Literal, TypedDict ->>>>>>> chore-debug-partnerstack +from typing import Literal, NotRequired, TypedDict import httpx from pydantic import TypeAdapter From ad6670ebcca1d3fde44c5f7f51d47e103599a274 Mon Sep 17 00:00:00 2001 From: hj24 Date: Wed, 8 Apr 2026 14:23:57 +0800 Subject: [PATCH 022/103] fix: correct quota info response --- api/services/billing_service.py | 27 ++++++++++++++-- .../services/test_billing_service.py | 32 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 9720a8e7c3..75dd3519ad 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -55,6 +55,27 @@ _quota_commit_adapter = TypeAdapter(QuotaCommitResult) _quota_release_adapter = TypeAdapter(QuotaReleaseResult) +class _TenantFeatureQuota(TypedDict): + usage: int + limit: int + reset_date: NotRequired[int] + + +class TenantFeatureQuotaInfo(TypedDict): + """Response of /quota/info. + + NOTE (hj24): + - Same convention as BillingInfo: billing may return int fields as str, + always keep non-strict mode to auto-coerce. + """ + + trigger_event: _TenantFeatureQuota + api_rate_limit: _TenantFeatureQuota + + +_tenant_feature_quota_info_adapter = TypeAdapter(TenantFeatureQuotaInfo) + + class _BillingQuota(TypedDict): size: int limit: int @@ -178,9 +199,11 @@ class BillingService: return usage_info @classmethod - def get_quota_info(cls, tenant_id: str): + def get_quota_info(cls, tenant_id: str) -> TenantFeatureQuotaInfo: params = {"tenant_id": tenant_id} - return cls._send_request("GET", "/quota/info", params=params) + return _tenant_feature_quota_info_adapter.validate_python( + cls._send_request("GET", "/quota/info", params=params) + ) @classmethod def quota_reserve( diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index 09806f608e..34f718ba02 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -640,6 +640,38 @@ class TestBillingServiceQuotaOperations: assert result["released"] == 1 assert isinstance(result["released"], int) + def test_get_quota_info_coerces_string_to_int(self, mock_send_request): + """Test that TypeAdapter coerces string values to int for get_quota_info.""" + mock_send_request.return_value = { + "trigger_event": {"usage": "42", "limit": "3000", "reset_date": "1700000000"}, + "api_rate_limit": {"usage": "10", "limit": "-1", "reset_date": "-1"}, + } + + result = BillingService.get_quota_info("t1") + + assert result["trigger_event"]["usage"] == 42 + assert isinstance(result["trigger_event"]["usage"], int) + assert result["trigger_event"]["limit"] == 3000 + assert isinstance(result["trigger_event"]["limit"], int) + assert result["trigger_event"]["reset_date"] == 1700000000 + assert isinstance(result["trigger_event"]["reset_date"], int) + assert result["api_rate_limit"]["limit"] == -1 + assert isinstance(result["api_rate_limit"]["limit"], int) + + def test_get_quota_info_accepts_int_values(self, mock_send_request): + """Test that get_quota_info works with native int values.""" + expected = { + "trigger_event": {"usage": 42, "limit": 3000, "reset_date": 1700000000}, + "api_rate_limit": {"usage": 0, "limit": -1}, + } + mock_send_request.return_value = expected + + result = BillingService.get_quota_info("t1") + + assert result["trigger_event"]["usage"] == 42 + assert result["trigger_event"]["limit"] == 3000 + assert result["api_rate_limit"]["limit"] == -1 + class TestBillingServiceRateLimitEnforcement: """Unit tests for rate limit enforcement mechanisms. From ae01a5d1371b17b8d8e6c329e53ab0de5a7049ab Mon Sep 17 00:00:00 2001 From: hj24 Date: Wed, 8 Apr 2026 14:42:52 +0800 Subject: [PATCH 023/103] fix: unit test mock --- api/tests/unit_tests/services/test_webhook_service.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/api/tests/unit_tests/services/test_webhook_service.py b/api/tests/unit_tests/services/test_webhook_service.py index 78049182ad..c86ed2debd 100644 --- a/api/tests/unit_tests/services/test_webhook_service.py +++ b/api/tests/unit_tests/services/test_webhook_service.py @@ -1100,12 +1100,11 @@ def test_trigger_workflow_execution_should_mark_tenant_rate_limited_when_quota_e "get_or_create_end_user_by_type", MagicMock(return_value=SimpleNamespace(id="end-user-1")), ) - quota_type = SimpleNamespace( - TRIGGER=SimpleNamespace( - consume=MagicMock(side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1)) - ) + monkeypatch.setattr( + service_module.QuotaService, + "reserve", + MagicMock(side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1)), ) - monkeypatch.setattr(service_module, "QuotaType", quota_type) mark_rate_limited_mock = MagicMock() monkeypatch.setattr(service_module.AppTriggerService, "mark_tenant_triggers_rate_limited", mark_rate_limited_mock) From 19ab594c7283fcd1d0e59450e9d4067308cfc134 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:55:05 +0800 Subject: [PATCH 024/103] try disable csp for test --- web/proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/proxy.ts b/web/proxy.ts index af9b290025..b7e4d12abb 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -24,7 +24,7 @@ export function proxy(request: NextRequest) { }, }) - const isWhiteListEnabled = !!env.NEXT_PUBLIC_CSP_WHITELIST && process.env.NODE_ENV === 'production' + const isWhiteListEnabled = !!env.NEXT_PUBLIC_CSP_WHITELIST if (!isWhiteListEnabled) return wrapResponseWithXFrameOptions(response, pathname) From 5b3616aa33fc13354a95611996a016518ca1675f Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:05:33 +0800 Subject: [PATCH 025/103] Revert "try disable csp for test" This reverts commit 19ab594c7283fcd1d0e59450e9d4067308cfc134. --- web/proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/proxy.ts b/web/proxy.ts index b7e4d12abb..af9b290025 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -24,7 +24,7 @@ export function proxy(request: NextRequest) { }, }) - const isWhiteListEnabled = !!env.NEXT_PUBLIC_CSP_WHITELIST + const isWhiteListEnabled = !!env.NEXT_PUBLIC_CSP_WHITELIST && process.env.NODE_ENV === 'production' if (!isWhiteListEnabled) return wrapResponseWithXFrameOptions(response, pathname) From abcf4a5730260380e15648319baea92aaaa3ee04 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:06:15 +0800 Subject: [PATCH 026/103] try disable csp --- web/proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/proxy.ts b/web/proxy.ts index af9b290025..7560021874 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -24,7 +24,7 @@ export function proxy(request: NextRequest) { }, }) - const isWhiteListEnabled = !!env.NEXT_PUBLIC_CSP_WHITELIST && process.env.NODE_ENV === 'production' + const isWhiteListEnabled = !(!!env.NEXT_PUBLIC_CSP_WHITELIST && process.env.NODE_ENV === 'production') if (!isWhiteListEnabled) return wrapResponseWithXFrameOptions(response, pathname) From d06ce2ef78f9815d6e2028ed843cdc43723ba92d Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:51:56 +0800 Subject: [PATCH 027/103] revert --- pnpm-lock.yaml | 13 +++++++------ pnpm-workspace.yaml | 2 +- web/proxy.ts | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e8c0970a6..d119723be5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -514,8 +514,8 @@ catalogs: specifier: 13.0.0 version: 13.0.0 vinext: - specifier: 0.0.40 - version: 0.0.40 + specifier: https://pkg.pr.new/vinext@b7b7724 + version: 0.0.5 vite-plugin-inspect: specifier: 12.0.0-beta.1 version: 12.0.0-beta.1 @@ -1150,7 +1150,7 @@ importers: version: 3.19.3 vinext: specifier: 'catalog:' - version: 0.0.40(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2) + version: https://pkg.pr.new/vinext@b7b7724(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' @@ -8312,8 +8312,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vinext@0.0.40: - resolution: {integrity: sha512-rs0z6G2el6kS/667ERKQjSMF3R8ZD2H9xDrnRntVOa6OBnyYcOMM/AVpOy/W1lxOkq6EYTO1OUD9DbNSWxRRJw==} + vinext@https://pkg.pr.new/vinext@b7b7724: + resolution: {integrity: sha512-9o83pv45wGISqRYWbBTB/EzHta3HZX3XO1ygbDZj3abDvtK5+KOfkw8qeXatqK3K1m3Rd9v6fbzqPNxy0x3gLg==, tarball: https://pkg.pr.new/vinext@b7b7724} + version: 0.0.5 engines: {node: '>=22'} hasBin: true peerDependencies: @@ -16512,7 +16513,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinext@0.0.40(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2): + vinext@https://pkg.pr.new/vinext@b7b7724(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2): dependencies: '@unpic/react': 1.0.2(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@vercel/og': 0.8.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6fe023066a..001ff1664f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -219,7 +219,7 @@ catalog: unist-util-visit: 5.1.0 use-context-selector: 2.0.0 uuid: 13.0.0 - vinext: 0.0.40 + vinext: https://pkg.pr.new/vinext@b7b7724 vite: npm:@voidzero-dev/vite-plus-core@0.1.16 vite-plugin-inspect: 12.0.0-beta.1 vite-plus: 0.1.16 diff --git a/web/proxy.ts b/web/proxy.ts index 7560021874..af9b290025 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -24,7 +24,7 @@ export function proxy(request: NextRequest) { }, }) - const isWhiteListEnabled = !(!!env.NEXT_PUBLIC_CSP_WHITELIST && process.env.NODE_ENV === 'production') + const isWhiteListEnabled = !!env.NEXT_PUBLIC_CSP_WHITELIST && process.env.NODE_ENV === 'production' if (!isWhiteListEnabled) return wrapResponseWithXFrameOptions(response, pathname) From 731adab593d504f6e941ce868f2ff2b5f9e63824 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:25:32 +0800 Subject: [PATCH 028/103] try pass nonce to next theme --- web/app/layout.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 03107f5d15..db0714da25 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -6,6 +6,7 @@ import GlobalPublicStoreProvider from '@/context/global-public-context' import { TanstackQueryInitializer } from '@/context/query-client' import { getDatasetMap } from '@/env' import { getLocaleOnServer } from '@/i18n-config/server' +import { headers } from '@/next/headers' import { ToastHost } from './components/base/ui/toast' import { TooltipProvider } from './components/base/ui/tooltip' import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder' @@ -31,6 +32,7 @@ const LocaleLayout = async ({ }) => { const locale = await getLocaleOnServer() const datasetMap = getDatasetMap() + const nonce = (await headers()).get('x-nonce') ?? undefined return ( @@ -62,6 +64,7 @@ const LocaleLayout = async ({ enableSystem disableTransitionOnChange enableColorScheme={false} + nonce={nonce} > From 6234776ae35928d675557c8ed65d3efb5cf19cba Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:34:46 +0800 Subject: [PATCH 029/103] Revert "try pass nonce to next theme" This reverts commit 731adab593d504f6e941ce868f2ff2b5f9e63824. --- web/app/layout.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/app/layout.tsx b/web/app/layout.tsx index db0714da25..03107f5d15 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -6,7 +6,6 @@ import GlobalPublicStoreProvider from '@/context/global-public-context' import { TanstackQueryInitializer } from '@/context/query-client' import { getDatasetMap } from '@/env' import { getLocaleOnServer } from '@/i18n-config/server' -import { headers } from '@/next/headers' import { ToastHost } from './components/base/ui/toast' import { TooltipProvider } from './components/base/ui/tooltip' import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder' @@ -32,7 +31,6 @@ const LocaleLayout = async ({ }) => { const locale = await getLocaleOnServer() const datasetMap = getDatasetMap() - const nonce = (await headers()).get('x-nonce') ?? undefined return ( @@ -64,7 +62,6 @@ const LocaleLayout = async ({ enableSystem disableTransitionOnChange enableColorScheme={false} - nonce={nonce} > From 56fd708cf6ae7db159794362e83bbec746c1a9a0 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:35:13 +0800 Subject: [PATCH 030/103] update --- pnpm-lock.yaml | 10 +++++----- pnpm-workspace.yaml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d119723be5..f8aeeb58ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -514,7 +514,7 @@ catalogs: specifier: 13.0.0 version: 13.0.0 vinext: - specifier: https://pkg.pr.new/vinext@b7b7724 + specifier: https://pkg.pr.new/vinext@fd532d3 version: 0.0.5 vite-plugin-inspect: specifier: 12.0.0-beta.1 @@ -1150,7 +1150,7 @@ importers: version: 3.19.3 vinext: specifier: 'catalog:' - version: https://pkg.pr.new/vinext@b7b7724(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2) + version: https://pkg.pr.new/vinext@fd532d3(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' @@ -8312,8 +8312,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vinext@https://pkg.pr.new/vinext@b7b7724: - resolution: {integrity: sha512-9o83pv45wGISqRYWbBTB/EzHta3HZX3XO1ygbDZj3abDvtK5+KOfkw8qeXatqK3K1m3Rd9v6fbzqPNxy0x3gLg==, tarball: https://pkg.pr.new/vinext@b7b7724} + vinext@https://pkg.pr.new/vinext@fd532d3: + resolution: {integrity: sha512-ofzIlYfhBfHnuDF4QtEiSUy/cod2d5puoimk5dIj30I6ucPMwGlHFBkghCqoZXSIYLG5MSFq3P69+FwlrGFQEQ==, tarball: https://pkg.pr.new/vinext@fd532d3} version: 0.0.5 engines: {node: '>=22'} hasBin: true @@ -16513,7 +16513,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinext@https://pkg.pr.new/vinext@b7b7724(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2): + vinext@https://pkg.pr.new/vinext@fd532d3(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2): dependencies: '@unpic/react': 1.0.2(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@vercel/og': 0.8.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 001ff1664f..e0f19d3201 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -219,7 +219,7 @@ catalog: unist-util-visit: 5.1.0 use-context-selector: 2.0.0 uuid: 13.0.0 - vinext: https://pkg.pr.new/vinext@b7b7724 + vinext: https://pkg.pr.new/vinext@fd532d3 vite: npm:@voidzero-dev/vite-plus-core@0.1.16 vite-plugin-inspect: 12.0.0-beta.1 vite-plus: 0.1.16 From 6dd0d790a0ca2e6d738545a44eec7535dd516be4 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:46:17 +0800 Subject: [PATCH 031/103] update --- pnpm-lock.yaml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a31ef5e237..869b425bb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -514,13 +514,8 @@ catalogs: specifier: 13.0.0 version: 13.0.0 vinext: -<<<<<<< HEAD - specifier: https://pkg.pr.new/vinext@fd532d3 - version: 0.0.5 -======= specifier: 0.0.41 version: 0.0.41 ->>>>>>> main vite-plugin-inspect: specifier: 12.0.0-beta.1 version: 12.0.0-beta.1 @@ -1155,11 +1150,7 @@ importers: version: 3.19.3 vinext: specifier: 'catalog:' -<<<<<<< HEAD - version: https://pkg.pr.new/vinext@fd532d3(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2) -======= version: 0.0.41(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.23(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)(typescript@6.0.2) ->>>>>>> main vite: specifier: npm:@voidzero-dev/vite-plus-core@0.1.16 version: '@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)' @@ -8312,14 +8303,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} -<<<<<<< HEAD - vinext@https://pkg.pr.new/vinext@fd532d3: - resolution: {integrity: sha512-ofzIlYfhBfHnuDF4QtEiSUy/cod2d5puoimk5dIj30I6ucPMwGlHFBkghCqoZXSIYLG5MSFq3P69+FwlrGFQEQ==, tarball: https://pkg.pr.new/vinext@fd532d3} - version: 0.0.5 -======= vinext@0.0.41: resolution: {integrity: sha512-fpQjNp6cIqjYGH2/kbhN2SdIYHEu79RdlII23SWsY1Qp7LM+je8GfTJH1sxw6dASxPhZKZB/jCmTm5d2/D25zw==} ->>>>>>> main engines: {node: '>=22'} hasBin: true peerDependencies: @@ -16515,11 +16500,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 -<<<<<<< HEAD - vinext@https://pkg.pr.new/vinext@fd532d3(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.22(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.4)(typescript@6.0.2): -======= vinext@0.0.41(@mdx-js/rollup@3.1.1(rollup@4.59.0))(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.23(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.16(@types/node@25.5.2)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@6.0.2)(yaml@2.8.3))(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(webpack@5.105.4(uglify-js@3.19.3)))(react@19.2.5)(typescript@6.0.2): ->>>>>>> main dependencies: '@unpic/react': 1.0.2(next@16.2.3(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(sass@1.98.0))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@vercel/og': 0.8.6 From 2d2b107a75056c6001001c6c1f80bfbce81fab72 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Mon, 13 Apr 2026 14:12:14 +0800 Subject: [PATCH 032/103] feat: implement app creation tracking and attribution handling --- web/app/components/app-initializer.tsx | 3 + .../app-list/__tests__/index.spec.tsx | 14 +- .../app/create-app-dialog/app-list/index.tsx | 12 +- .../create-app-modal/__tests__/index.spec.tsx | 13 +- .../components/app/create-app-modal/index.tsx | 8 +- .../__tests__/index.spec.tsx | 12 +- .../app/create-from-dsl-modal/index.tsx | 20 +- .../components/apps/__tests__/index.spec.tsx | 141 +++++++++++++- web/app/components/apps/index.tsx | 20 +- .../explore/app-list/__tests__/index.spec.tsx | 37 ++++ web/app/components/explore/app-list/index.tsx | 23 ++- web/app/signup/set-password/page.tsx | 2 + .../__tests__/create-app-tracking.spec.ts | 94 ++++++++++ web/utils/create-app-tracking.ts | 176 ++++++++++++++++++ 14 files changed, 511 insertions(+), 64 deletions(-) create mode 100644 web/utils/__tests__/create-app-tracking.spec.ts create mode 100644 web/utils/create-app-tracking.ts diff --git a/web/app/components/app-initializer.tsx b/web/app/components/app-initializer.tsx index e08ece6666..30d8f3e410 100644 --- a/web/app/components/app-initializer.tsx +++ b/web/app/components/app-initializer.tsx @@ -9,6 +9,7 @@ import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, } from '@/app/education-apply/constants' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' +import { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking' import { sendGAEvent } from '@/utils/gtag' import { fetchSetupStatusWithCache } from '@/utils/setup-status' import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect' @@ -45,6 +46,8 @@ export const AppInitializer = ({ (async () => { const action = searchParams.get('action') + rememberCreateAppExternalAttribution({ searchParams }) + if (oauthNewUser) { let utmInfo = null const utmInfoStr = Cookies.get('utm_info') diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index 3ebc5f7157..486bb98ac1 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -4,7 +4,6 @@ import { AppModeEnum } from '@/types/app' import Apps from '../index' const mockUseExploreAppList = vi.fn() -const mockTrackEvent = vi.fn() const mockImportDSL = vi.fn() const mockFetchAppDetail = vi.fn() const mockHandleCheckPluginDependencies = vi.fn() @@ -12,6 +11,7 @@ const mockGetRedirection = vi.fn() const mockPush = vi.fn() const mockToastSuccess = vi.fn() const mockToastError = vi.fn() +const mockTrackCreateApp = vi.fn() let latestDebounceFn = () => {} vi.mock('ahooks', () => ({ @@ -92,8 +92,8 @@ vi.mock('@/app/components/base/ui/toast', () => ({ error: (...args: unknown[]) => mockToastError(...args), }, })) -vi.mock('@/app/components/base/amplitude', () => ({ - trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +vi.mock('@/utils/create-app-tracking', () => ({ + trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args), })) vi.mock('@/service/apps', () => ({ importDSL: (...args: unknown[]) => mockImportDSL(...args), @@ -246,10 +246,10 @@ describe('Apps', () => { })) }) - expect(mockTrackEvent).toHaveBeenCalledWith('create_app_with_template', expect.objectContaining({ - template_id: 'Alpha', - template_name: 'Alpha', - })) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + source: 'studio_template_list', + templateId: 'Alpha', + }) expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated') expect(onSuccess).toHaveBeenCalled() expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('created-app-id') diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 1aa40d2014..1f8e34be71 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -8,7 +8,6 @@ import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import AppTypeSelector from '@/app/components/app/type-selector' -import { trackEvent } from '@/app/components/base/amplitude' import Divider from '@/app/components/base/divider' import Input from '@/app/components/base/input' import Loading from '@/app/components/base/loading' @@ -25,6 +24,7 @@ import { useExploreAppList } from '@/service/use-explore' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' +import { trackCreateApp } from '@/utils/create-app-tracking' import AppCard from '../app-card' import Sidebar, { AppCategories, AppCategoryLabel } from './sidebar' @@ -127,14 +127,8 @@ const Apps = ({ icon_background, description, }) - - // Track app creation from template - trackEvent('create_app_with_template', { - app_mode: mode, - template_id: currApp?.app.id, - template_name: currApp?.app.name, - description, - }) + if (currApp?.app.id) + trackCreateApp({ source: 'studio_template_list', templateId: currApp.app.id }) setIsShowCreateModal(false) toast.success(t('newApp.appCreated', { ns: 'app' })) diff --git a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx index ee24ab4006..8724778777 100644 --- a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx @@ -1,7 +1,6 @@ import type { App } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { trackEvent } from '@/app/components/base/amplitude' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' @@ -10,6 +9,7 @@ import { useRouter } from '@/next/navigation' import { createApp } from '@/service/apps' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' +import { trackCreateApp } from '@/utils/create-app-tracking' import CreateAppModal from '../index' const ahooksMocks = vi.hoisted(() => ({ @@ -31,8 +31,8 @@ vi.mock('ahooks', () => ({ vi.mock('@/next/navigation', () => ({ useRouter: vi.fn(), })) -vi.mock('@/app/components/base/amplitude', () => ({ - trackEvent: vi.fn(), +vi.mock('@/utils/create-app-tracking', () => ({ + trackCreateApp: vi.fn(), })) vi.mock('@/service/apps', () => ({ createApp: vi.fn(), @@ -87,7 +87,7 @@ vi.mock('@/hooks/use-theme', () => ({ const mockUseRouter = vi.mocked(useRouter) const mockPush = vi.fn() const mockCreateApp = vi.mocked(createApp) -const mockTrackEvent = vi.mocked(trackEvent) +const mockTrackCreateApp = vi.mocked(trackCreateApp) const mockGetRedirection = vi.mocked(getRedirection) const mockUseProviderContext = vi.mocked(useProviderContext) const mockUseAppContext = vi.mocked(useAppContext) @@ -178,10 +178,7 @@ describe('CreateAppModal', () => { mode: AppModeEnum.ADVANCED_CHAT, })) - expect(mockTrackEvent).toHaveBeenCalledWith('create_app', { - app_mode: AppModeEnum.ADVANCED_CHAT, - description: '', - }) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_blank' }) expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated') expect(onSuccess).toHaveBeenCalled() expect(onClose).toHaveBeenCalled() diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index f2ced9b6c0..7bcaff7a31 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -6,7 +6,6 @@ import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon import { useDebounceFn, useKeyPress } from 'ahooks' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { trackEvent } from '@/app/components/base/amplitude' import AppIcon from '@/app/components/base/app-icon' import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' @@ -25,6 +24,7 @@ import { createApp } from '@/service/apps' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' +import { trackCreateApp } from '@/utils/create-app-tracking' import { basePath } from '@/utils/var' import AppIconPicker from '../../base/app-icon-picker' import ShortcutsName from '../../workflow/shortcuts-name' @@ -80,11 +80,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: mode: appMode, }) - // Track app creation success - trackEvent('create_app', { - app_mode: appMode, - description, - }) + trackCreateApp({ source: 'studio_blank' }) toast.success(t('newApp.appCreated', { ns: 'app' })) onSuccess() diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx index c1ffbc22e8..4a1ef74450 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx @@ -7,7 +7,7 @@ import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index' const mockPush = vi.fn() const mockImportDSL = vi.fn() const mockImportDSLConfirm = vi.fn() -const mockTrackEvent = vi.fn() +const mockTrackCreateApp = vi.fn() const mockHandleCheckPluginDependencies = vi.fn() const mockGetRedirection = vi.fn() const toastMocks = vi.hoisted(() => ({ @@ -43,8 +43,8 @@ vi.mock('@/next/navigation', () => ({ }), })) -vi.mock('@/app/components/base/amplitude', () => ({ - trackEvent: (...args: unknown[]) => mockTrackEvent(...args), +vi.mock('@/utils/create-app-tracking', () => ({ + trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args), })) vi.mock('@/service/apps', () => ({ @@ -196,10 +196,7 @@ describe('CreateFromDSLModal', () => { mode: DSLImportMode.YAML_URL, yaml_url: 'https://example.com/app.yml', }) - expect(mockTrackEvent).toHaveBeenCalledWith('create_app_with_dsl', expect.objectContaining({ - creation_method: 'dsl_url', - has_warnings: false, - })) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_upload' }) expect(handleSuccess).toHaveBeenCalledTimes(1) expect(handleClose).toHaveBeenCalledTimes(1) expect(localStorage.getItem(NEED_REFRESH_APP_LIST_KEY)).toBe('1') @@ -305,6 +302,7 @@ describe('CreateFromDSLModal', () => { expect(mockImportDSLConfirm).toHaveBeenCalledWith({ import_id: 'import-3', }) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_upload' }) }) it('should ignore empty import responses and prevent duplicate submissions while a request is in flight', async () => { diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index dd17655e3c..b31911d9e2 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -6,7 +6,6 @@ import { useDebounceFn, useKeyPress } from 'ahooks' import { noop } from 'es-toolkit/function' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { trackEvent } from '@/app/components/base/amplitude' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' @@ -27,6 +26,7 @@ import { } from '@/service/apps' import { getRedirection } from '@/utils/app-redirection' import { cn } from '@/utils/classnames' +import { trackCreateApp } from '@/utils/create-app-tracking' import ShortcutsName from '../../workflow/shortcuts-name' import Uploader from './uploader' @@ -112,12 +112,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS return const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { - // Track app creation from DSL import - trackEvent('create_app_with_dsl', { - app_mode, - creation_method: currentTab === CreateFromDSLModalTab.FROM_FILE ? 'dsl_file' : 'dsl_url', - has_warnings: status === DSLImportStatus.COMPLETED_WITH_WARNINGS, - }) + trackCreateApp({ source: 'studio_upload' }) if (onSuccess) onSuccess() @@ -179,6 +174,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS const { status, app_id, app_mode } = response if (status === DSLImportStatus.COMPLETED) { + trackCreateApp({ source: 'studio_upload' }) if (onSuccess) onSuccess() if (onClose) @@ -228,7 +224,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS isShow={show} onClose={noop} > -
+
{t('importFromDSL', { ns: 'app' })}
-
+
{ tabs.map(tab => (
-
DSL URL
+
DSL URL
-
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
-
+
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
+
{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}
{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}

diff --git a/web/app/components/apps/__tests__/index.spec.tsx b/web/app/components/apps/__tests__/index.spec.tsx index da4fbc2d44..c6a552fa73 100644 --- a/web/app/components/apps/__tests__/index.spec.tsx +++ b/web/app/components/apps/__tests__/index.spec.tsx @@ -1,12 +1,48 @@ import type { ReactNode } from 'react' +import type { App } from '@/models/explore' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' +import { useContextSelector } from 'use-context-selector' +import AppListContext from '@/context/app-list-context' +import { fetchAppDetail } from '@/service/explore' +import { AppModeEnum } from '@/types/app' import Apps from '../index' let documentTitleCalls: string[] = [] let educationInitCalls: number = 0 +const mockHandleImportDSL = vi.fn() +const mockHandleImportDSLConfirm = vi.fn() +const mockTrackCreateApp = vi.fn() +const mockFetchAppDetail = vi.mocked(fetchAppDetail) + +const mockTemplateApp: App = { + app_id: 'template-1', + category: 'Assistant', + app: { + id: 'template-1', + mode: AppModeEnum.CHAT, + icon_type: 'emoji', + icon: '🤖', + icon_background: '#fff', + icon_url: '', + name: 'Sample App', + description: 'Sample App', + use_icon_as_answer_icon: false, + }, + description: 'Sample App', + can_trial: true, + copyright: '', + privacy_policy: null, + custom_disclaimer: null, + position: 1, + is_listed: true, + install_count: 0, + installed: false, + editable: false, + is_agent: false, +} vi.mock('@/hooks/use-document-title', () => ({ default: (title: string) => { @@ -22,17 +58,80 @@ vi.mock('@/app/education-apply/hooks', () => ({ vi.mock('@/hooks/use-import-dsl', () => ({ useImportDSL: () => ({ - handleImportDSL: vi.fn(), - handleImportDSLConfirm: vi.fn(), + handleImportDSL: mockHandleImportDSL, + handleImportDSLConfirm: mockHandleImportDSLConfirm, versions: [], isFetching: false, }), })) -vi.mock('../list', () => ({ - default: () => { - return React.createElement('div', { 'data-testid': 'apps-list' }, 'Apps List') - }, +vi.mock('../list', () => { + const MockList = () => { + const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel) + return React.createElement( + 'div', + { 'data-testid': 'apps-list' }, + React.createElement('span', null, 'Apps List'), + React.createElement( + 'button', + { + 'data-testid': 'open-preview', + 'onClick': () => setShowTryAppPanel(true, { + appId: mockTemplateApp.app_id, + app: mockTemplateApp, + }), + }, + 'Open Preview', + ), + ) + } + + return { default: MockList } +}) + +vi.mock('../../explore/try-app', () => ({ + default: ({ onCreate, onClose }: { onCreate: () => void, onClose: () => void }) => ( +
+ + +
+ ), +})) + +vi.mock('../../explore/create-app-modal', () => ({ + default: ({ show, onConfirm, onHide }: { show: boolean, onConfirm: (payload: Record) => Promise, onHide: () => void }) => show + ? ( +
+ + +
+ ) + : null, +})) + +vi.mock('../../app/create-from-dsl-modal/dsl-confirm-modal', () => ({ + default: ({ onConfirm }: { onConfirm: () => void }) => ( + + ), +})) + +vi.mock('@/service/explore', () => ({ + fetchAppDetail: vi.fn(), +})) + +vi.mock('@/utils/create-app-tracking', () => ({ + trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args), })) describe('Apps', () => { @@ -59,6 +158,14 @@ describe('Apps', () => { vi.clearAllMocks() documentTitleCalls = [] educationInitCalls = 0 + mockFetchAppDetail.mockResolvedValue({ + id: 'template-1', + name: 'Sample App', + icon: '🤖', + icon_background: '#fff', + mode: AppModeEnum.CHAT, + export_data: 'yaml-content', + }) }) describe('Rendering', () => { @@ -116,6 +223,26 @@ describe('Apps', () => { ) expect(screen.getByTestId('apps-list')).toBeInTheDocument() }) + + it('should track template preview creation after a successful import', async () => { + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + + renderWithClient() + + fireEvent.click(screen.getByTestId('open-preview')) + fireEvent.click(await screen.findByTestId('try-app-create')) + fireEvent.click(await screen.findByTestId('confirm-create')) + + await waitFor(() => { + expect(mockFetchAppDetail).toHaveBeenCalledWith('template-1') + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + source: 'studio_template_preview', + templateId: 'template-1', + }) + }) + }) }) describe('Styling', () => { diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index b6ca60bd7b..1bc825306a 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -10,6 +10,7 @@ import { useImportDSL } from '@/hooks/use-import-dsl' import { DSLImportMode } from '@/models/app' import dynamic from '@/next/dynamic' import { fetchAppDetail } from '@/service/explore' +import { trackCreateApp } from '@/utils/create-app-tracking' import List from './list' const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false }) @@ -40,6 +41,13 @@ const Apps = () => { const handleShowFromTryApp = useCallback(() => { setIsShowCreateModal(true) }, []) + const trackCurrentCreateApp = useCallback(() => { + const templateId = currApp?.app.id + if (!templateId) + return + + trackCreateApp({ source: 'studio_template_preview', templateId }) + }, [currApp?.app.id]) const [controlRefreshList, setControlRefreshList] = useState(0) const [controlHideCreateFromTemplatePanel, setControlHideCreateFromTemplatePanel] = useState(0) @@ -59,11 +67,14 @@ const Apps = () => { const onConfirmDSL = useCallback(async () => { await handleImportDSLConfirm({ - onSuccess, + onSuccess: () => { + trackCurrentCreateApp() + onSuccess() + }, }) - }, [handleImportDSLConfirm, onSuccess]) + }, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp]) - const onCreate: CreateAppModalProps['onConfirm'] = async ({ + const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, icon_type, icon, @@ -86,13 +97,14 @@ const Apps = () => { } await handleImportDSL(payload, { onSuccess: () => { + trackCurrentCreateApp() setIsShowCreateModal(false) }, onPending: () => { setShowDSLConfirmModal(true) }, }) - } + }, [currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp]) return ( ({ useExploreAppList: () => ({ @@ -45,6 +46,9 @@ vi.mock('@/hooks/use-import-dsl', () => ({ isFetching: false, }), })) +vi.mock('@/utils/create-app-tracking', () => ({ + trackCreateApp: (...args: unknown[]) => mockTrackCreateApp(...args), +})) vi.mock('@/app/components/explore/create-app-modal', () => ({ default: (props: CreateAppModalProps) => { @@ -235,6 +239,10 @@ describe('AppList', () => { fireEvent.click(screen.getByTestId('dsl-confirm')) await waitFor(() => { expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + source: 'explore_template_list', + templateId: 'app-basic-id', + }) expect(onSuccess).toHaveBeenCalledTimes(1) }) }) @@ -337,6 +345,10 @@ describe('AppList', () => { await waitFor(() => { expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() }) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + source: 'explore_template_list', + templateId: 'app-basic-id', + }) }) it('should cancel DSL confirm modal', async () => { @@ -385,6 +397,31 @@ describe('AppList', () => { }) }) + it('should track preview source when creation starts from try app details', async () => { + vi.useRealTimers() + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + }; + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' }) + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + + renderAppList(true) + + fireEvent.click(screen.getByText('explore.appCard.try')) + fireEvent.click(screen.getByTestId('try-app-create')) + fireEvent.click(await screen.findByTestId('confirm-create')) + + await waitFor(() => { + expect(mockTrackCreateApp).toHaveBeenCalledWith({ + source: 'explore_template_preview', + templateId: 'app-basic-id', + }) + }) + }) + it('should close try app panel when close is clicked', () => { mockExploreData = { categories: ['Writing'], diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index d508f141b4..7eaef06f11 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -26,6 +26,7 @@ import { fetchAppDetail } from '@/service/explore' import { useMembers } from '@/service/use-common' import { useExploreAppList } from '@/service/use-explore' import { cn } from '@/utils/classnames' +import { trackCreateApp } from '@/utils/create-app-tracking' import TryApp from '../try-app' import s from './style.module.css' @@ -91,6 +92,7 @@ const Apps = ({ const [currApp, setCurrApp] = useState(null) const [isShowCreateModal, setIsShowCreateModal] = useState(false) + const [createAppSource, setCreateAppSource] = useState<'explore_template_list' | 'explore_template_preview'>('explore_template_list') const { handleImportDSL, @@ -110,10 +112,18 @@ const Apps = ({ }, []) const handleShowFromTryApp = useCallback(() => { setCurrApp(currentTryApp?.app || null) + setCreateAppSource('explore_template_preview') setIsShowCreateModal(true) }, [currentTryApp?.app]) + const trackCurrentCreateApp = useCallback(() => { + const templateId = currApp?.app.id + if (!templateId) + return - const onCreate: CreateAppModalProps['onConfirm'] = async ({ + trackCreateApp({ source: createAppSource, templateId }) + }, [createAppSource, currApp?.app.id]) + + const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, icon_type, icon, @@ -136,19 +146,23 @@ const Apps = ({ } await handleImportDSL(payload, { onSuccess: () => { + trackCurrentCreateApp() setIsShowCreateModal(false) }, onPending: () => { setShowDSLConfirmModal(true) }, }) - } + }, [currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp]) const onConfirmDSL = useCallback(async () => { await handleImportDSLConfirm({ - onSuccess, + onSuccess: () => { + trackCurrentCreateApp() + onSuccess?.() + }, }) - }, [handleImportDSLConfirm, onSuccess]) + }, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp]) if (isLoading) { return ( @@ -226,6 +240,7 @@ const Apps = ({ canCreate={hasEditPermission} onCreate={() => { setCurrApp(app) + setCreateAppSource('explore_template_list') setIsShowCreateModal(true) }} onTry={handleTryApp} diff --git a/web/app/signup/set-password/page.tsx b/web/app/signup/set-password/page.tsx index 39c15e6161..2da6c670ad 100644 --- a/web/app/signup/set-password/page.tsx +++ b/web/app/signup/set-password/page.tsx @@ -11,6 +11,7 @@ import { validPassword } from '@/config' import { useRouter, useSearchParams } from '@/next/navigation' import { useMailRegister } from '@/service/use-common' import { cn } from '@/utils/classnames' +import { rememberCreateAppExternalAttribution } from '@/utils/create-app-tracking' import { sendGAEvent } from '@/utils/gtag' const parseUtmInfo = () => { @@ -68,6 +69,7 @@ const ChangePasswordForm = () => { const { result } = res as MailRegisterResponse if (result === 'success') { const utmInfo = parseUtmInfo() + rememberCreateAppExternalAttribution({ utmInfo }) trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', { method: 'email', ...utmInfo, diff --git a/web/utils/__tests__/create-app-tracking.spec.ts b/web/utils/__tests__/create-app-tracking.spec.ts new file mode 100644 index 0000000000..778a062a60 --- /dev/null +++ b/web/utils/__tests__/create-app-tracking.spec.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as amplitude from '@/app/components/base/amplitude' +import { + buildCreateAppEventPayload, + extractExternalCreateAppAttribution, + rememberCreateAppExternalAttribution, + trackCreateApp, +} from '../create-app-tracking' + +describe('create-app-tracking', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(amplitude, 'trackEvent').mockImplementation(() => {}) + window.sessionStorage.clear() + window.history.replaceState({}, '', '/apps') + }) + + describe('extractExternalCreateAppAttribution', () => { + it('should map campaign links to external attribution', () => { + const attribution = extractExternalCreateAppAttribution({ + searchParams: new URLSearchParams('utm_source=x&slug=how-to-build-rag-agent'), + }) + + expect(attribution).toEqual({ + utmSource: 'twitter/x', + utmCampaign: 'how-to-build-rag-agent', + }) + }) + + it('should map newsletter and blog sources to blog', () => { + expect(extractExternalCreateAppAttribution({ + searchParams: new URLSearchParams('utm_source=newsletter'), + })).toEqual({ utmSource: 'blog' }) + + expect(extractExternalCreateAppAttribution({ + utmInfo: { utm_source: 'dify_blog', slug: 'launch-week' }, + })).toEqual({ + utmSource: 'blog', + utmCampaign: 'launch-week', + }) + }) + }) + + describe('buildCreateAppEventPayload', () => { + it('should build template payloads with template id', () => { + expect(buildCreateAppEventPayload({ + source: 'explore_template_preview', + templateId: 'template-1', + })).toEqual({ + source: 'explore_template_preview', + template_id: 'template-1', + }) + }) + + it('should prefer external attribution when present', () => { + expect(buildCreateAppEventPayload( + { + source: 'studio_template_list', + templateId: 'template-2', + }, + { + utmSource: 'linkedin', + utmCampaign: 'agent-launch', + }, + )).toEqual({ + source: 'external', + utm_source: 'linkedin', + utm_campaign: 'agent-launch', + }) + }) + }) + + describe('trackCreateApp', () => { + it('should track remembered external attribution once before falling back to internal source', () => { + rememberCreateAppExternalAttribution({ + searchParams: new URLSearchParams('utm_source=newsletter&slug=how-to-build-rag-agent'), + }) + + trackCreateApp({ source: 'studio_blank' }) + + expect(amplitude.trackEvent).toHaveBeenNthCalledWith(1, 'create_app', { + source: 'external', + utm_source: 'blog', + utm_campaign: 'how-to-build-rag-agent', + }) + + trackCreateApp({ source: 'studio_blank' }) + + expect(amplitude.trackEvent).toHaveBeenNthCalledWith(2, 'create_app', { + source: 'studio_blank', + }) + }) + }) +}) diff --git a/web/utils/create-app-tracking.ts b/web/utils/create-app-tracking.ts new file mode 100644 index 0000000000..b2d377a154 --- /dev/null +++ b/web/utils/create-app-tracking.ts @@ -0,0 +1,176 @@ +import Cookies from 'js-cookie' +import { trackEvent } from '@/app/components/base/amplitude' + +const CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY = 'create_app_external_attribution' + +const EXTERNAL_UTM_SOURCE_MAP = { + blog: 'blog', + dify_blog: 'blog', + linkedin: 'linkedin', + newsletter: 'blog', + twitter: 'twitter/x', + x: 'twitter/x', +} as const + +type SearchParamReader = { + get: (name: string) => string | null +} + +type CreateAppSource = 'external' | 'explore_template_list' | 'explore_template_preview' | 'studio_blank' | 'studio_template_list' | 'studio_template_preview' | 'studio_upload' + +type TemplateCreateAppSource = Extract + +type NonTemplateCreateAppSource = Extract + +type TrackCreateAppParams = { source: TemplateCreateAppSource, templateId: string } | { source: NonTemplateCreateAppSource } + +type ExternalCreateAppAttribution = { + utmSource: typeof EXTERNAL_UTM_SOURCE_MAP[keyof typeof EXTERNAL_UTM_SOURCE_MAP] + utmCampaign?: string +} + +const normalizeString = (value?: string | null) => { + const trimmed = value?.trim() + return trimmed || undefined +} + +const getObjectStringValue = (value: unknown) => { + return typeof value === 'string' ? normalizeString(value) : undefined +} + +const getSearchParamValue = (searchParams?: SearchParamReader | null, key?: string) => { + if (!searchParams || !key) + return undefined + return normalizeString(searchParams.get(key)) +} + +const parseJSONRecord = (value?: string | null): Record | null => { + if (!value) + return null + + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' ? parsed as Record : null + } + catch { + return null + } +} + +const getCookieUtmInfo = () => { + return parseJSONRecord(Cookies.get('utm_info')) +} + +const mapExternalUtmSource = (value?: string) => { + if (!value) + return undefined + + const normalized = value.toLowerCase() + return EXTERNAL_UTM_SOURCE_MAP[normalized as keyof typeof EXTERNAL_UTM_SOURCE_MAP] +} + +export const extractExternalCreateAppAttribution = ({ + searchParams, + utmInfo, +}: { + searchParams?: SearchParamReader | null + utmInfo?: Record | null +}) => { + const rawSource = getSearchParamValue(searchParams, 'utm_source') ?? getObjectStringValue(utmInfo?.utm_source) + const mappedSource = mapExternalUtmSource(rawSource) + + if (!mappedSource) + return null + + const utmCampaign = getSearchParamValue(searchParams, 'slug') + ?? getSearchParamValue(searchParams, 'utm_campaign') + ?? getObjectStringValue(utmInfo?.slug) + ?? getObjectStringValue(utmInfo?.utm_campaign) + + return { + utmSource: mappedSource, + ...(utmCampaign ? { utmCampaign } : {}), + } satisfies ExternalCreateAppAttribution +} + +const readRememberedExternalCreateAppAttribution = (): ExternalCreateAppAttribution | null => { + if (typeof window === 'undefined') + return null + + return parseJSONRecord(window.sessionStorage.getItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY)) as ExternalCreateAppAttribution | null +} + +const writeRememberedExternalCreateAppAttribution = (attribution: ExternalCreateAppAttribution) => { + if (typeof window === 'undefined') + return + + window.sessionStorage.setItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY, JSON.stringify(attribution)) +} + +const clearRememberedExternalCreateAppAttribution = () => { + if (typeof window === 'undefined') + return + + window.sessionStorage.removeItem(CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY) +} + +export const rememberCreateAppExternalAttribution = ({ + searchParams, + utmInfo, +}: { + searchParams?: SearchParamReader | null + utmInfo?: Record | null +} = {}) => { + const attribution = extractExternalCreateAppAttribution({ + searchParams, + utmInfo: utmInfo ?? getCookieUtmInfo(), + }) + + if (attribution) + writeRememberedExternalCreateAppAttribution(attribution) + + return attribution +} + +const resolveCurrentExternalCreateAppAttribution = () => { + if (typeof window === 'undefined') + return null + + return rememberCreateAppExternalAttribution({ + searchParams: new URLSearchParams(window.location.search), + }) ?? readRememberedExternalCreateAppAttribution() +} + +export const buildCreateAppEventPayload = ( + params: TrackCreateAppParams, + externalAttribution?: ExternalCreateAppAttribution | null, +) => { + if (externalAttribution) { + return { + source: 'external', + utm_source: externalAttribution.utmSource, + ...(externalAttribution.utmCampaign ? { utm_campaign: externalAttribution.utmCampaign } : {}), + } satisfies Record + } + + if ('templateId' in params) { + return { + source: params.source, + template_id: params.templateId, + } satisfies Record + } + + return { + source: params.source, + } satisfies Record +} + +export const trackCreateApp = (params: TrackCreateAppParams) => { + const externalAttribution = resolveCurrentExternalCreateAppAttribution() + const payload = buildCreateAppEventPayload(params, externalAttribution) + + if (externalAttribution) + clearRememberedExternalCreateAppAttribution() + + trackEvent('create_app', payload) +} From 2c121b38afc3d15e8a1598f3a1a48ef6b53dad31 Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Mon, 13 Apr 2026 14:37:13 +0800 Subject: [PATCH 033/103] refactor: update app creation tracking to use appMode instead of source identifiers --- .../app-list/__tests__/index.spec.tsx | 3 +- .../app/create-app-dialog/app-list/index.tsx | 3 +- .../create-app-modal/__tests__/index.spec.tsx | 2 +- .../components/app/create-app-modal/index.tsx | 2 +- .../__tests__/index.spec.tsx | 13 ++-- .../app/create-from-dsl-modal/index.tsx | 4 +- .../components/apps/__tests__/index.spec.tsx | 3 +- web/app/components/apps/index.tsx | 13 ++-- .../base/amplitude/AmplitudeProvider.tsx | 6 +- .../explore/app-list/__tests__/index.spec.tsx | 19 +++--- web/app/components/explore/app-list/index.tsx | 16 +++-- .../__tests__/create-app-tracking.spec.ts | 62 +++++++++++++++---- web/utils/create-app-tracking.ts | 39 +++++++----- 13 files changed, 115 insertions(+), 70 deletions(-) diff --git a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx index 486bb98ac1..a319bb58f7 100644 --- a/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/__tests__/index.spec.tsx @@ -247,8 +247,7 @@ describe('Apps', () => { }) expect(mockTrackCreateApp).toHaveBeenCalledWith({ - source: 'studio_template_list', - templateId: 'Alpha', + appMode: AppModeEnum.CHAT, }) expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated') expect(onSuccess).toHaveBeenCalled() diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index 1f8e34be71..daf49115c8 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -127,8 +127,7 @@ const Apps = ({ icon_background, description, }) - if (currApp?.app.id) - trackCreateApp({ source: 'studio_template_list', templateId: currApp.app.id }) + trackCreateApp({ appMode: mode }) setIsShowCreateModal(false) toast.success(t('newApp.appCreated', { ns: 'app' })) diff --git a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx index 8724778777..3e06b89f0e 100644 --- a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx @@ -178,7 +178,7 @@ describe('CreateAppModal', () => { mode: AppModeEnum.ADVANCED_CHAT, })) - expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_blank' }) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.ADVANCED_CHAT }) expect(mockToastSuccess).toHaveBeenCalledWith('app.newApp.appCreated') expect(onSuccess).toHaveBeenCalled() expect(onClose).toHaveBeenCalled() diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index 7bcaff7a31..96c3045c59 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -80,7 +80,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: mode: appMode, }) - trackCreateApp({ source: 'studio_blank' }) + trackCreateApp({ appMode: app.mode }) toast.success(t('newApp.appCreated', { ns: 'app' })) onSuccess() diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx index 4a1ef74450..e106cc7eb3 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { DSLImportMode, DSLImportStatus } from '@/models/app' +import { AppModeEnum } from '@/types/app' import CreateFromDSLModal, { CreateFromDSLModalTab } from '../index' const mockPush = vi.fn() @@ -172,7 +173,7 @@ describe('CreateFromDSLModal', () => { id: 'import-1', status: DSLImportStatus.COMPLETED, app_id: 'app-1', - app_mode: 'chat', + app_mode: AppModeEnum.CHAT, }) render( @@ -196,7 +197,7 @@ describe('CreateFromDSLModal', () => { mode: DSLImportMode.YAML_URL, yaml_url: 'https://example.com/app.yml', }) - expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_upload' }) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.CHAT }) expect(handleSuccess).toHaveBeenCalledTimes(1) expect(handleClose).toHaveBeenCalledTimes(1) expect(localStorage.getItem(NEED_REFRESH_APP_LIST_KEY)).toBe('1') @@ -209,7 +210,7 @@ describe('CreateFromDSLModal', () => { id: 'import-2', status: DSLImportStatus.COMPLETED_WITH_WARNINGS, app_id: 'app-2', - app_mode: 'chat', + app_mode: AppModeEnum.CHAT, }) render( @@ -272,7 +273,7 @@ describe('CreateFromDSLModal', () => { mockImportDSLConfirm.mockResolvedValue({ status: DSLImportStatus.COMPLETED, app_id: 'app-3', - app_mode: 'workflow', + app_mode: AppModeEnum.WORKFLOW, }) render( @@ -302,7 +303,7 @@ describe('CreateFromDSLModal', () => { expect(mockImportDSLConfirm).toHaveBeenCalledWith({ import_id: 'import-3', }) - expect(mockTrackCreateApp).toHaveBeenCalledWith({ source: 'studio_upload' }) + expect(mockTrackCreateApp).toHaveBeenCalledWith({ appMode: AppModeEnum.WORKFLOW }) }) it('should ignore empty import responses and prevent duplicate submissions while a request is in flight', async () => { @@ -330,7 +331,7 @@ describe('CreateFromDSLModal', () => { id: 'import-in-flight', status: DSLImportStatus.COMPLETED, app_id: 'app-1', - app_mode: 'chat', + app_mode: AppModeEnum.CHAT, }) }) diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index b31911d9e2..77000dbf0a 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -112,7 +112,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS return const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { - trackCreateApp({ source: 'studio_upload' }) + trackCreateApp({ appMode: app_mode }) if (onSuccess) onSuccess() @@ -174,7 +174,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS const { status, app_id, app_mode } = response if (status === DSLImportStatus.COMPLETED) { - trackCreateApp({ source: 'studio_upload' }) + trackCreateApp({ appMode: app_mode }) if (onSuccess) onSuccess() if (onClose) diff --git a/web/app/components/apps/__tests__/index.spec.tsx b/web/app/components/apps/__tests__/index.spec.tsx index c6a552fa73..aae862c865 100644 --- a/web/app/components/apps/__tests__/index.spec.tsx +++ b/web/app/components/apps/__tests__/index.spec.tsx @@ -238,8 +238,7 @@ describe('Apps', () => { await waitFor(() => { expect(mockFetchAppDetail).toHaveBeenCalledWith('template-1') expect(mockTrackCreateApp).toHaveBeenCalledWith({ - source: 'studio_template_preview', - templateId: 'template-1', + appMode: AppModeEnum.CHAT, }) }) }) diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index 1bc825306a..9bf07e81e6 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -1,7 +1,7 @@ 'use client' import type { CreateAppModalProps } from '../explore/create-app-modal' import type { TryAppSelection } from '@/types/try-app' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useEducationInit } from '@/app/education-apply/hooks' import AppListContext from '@/context/app-list-context' @@ -24,6 +24,7 @@ const Apps = () => { useEducationInit() const [currentTryAppParams, setCurrentTryAppParams] = useState(undefined) + const currentCreateAppModeRef = useRef(null) const currApp = currentTryAppParams?.app const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false) const hideTryAppPanel = useCallback(() => { @@ -42,12 +43,11 @@ const Apps = () => { setIsShowCreateModal(true) }, []) const trackCurrentCreateApp = useCallback(() => { - const templateId = currApp?.app.id - if (!templateId) + if (!currentCreateAppModeRef.current) return - trackCreateApp({ source: 'studio_template_preview', templateId }) - }, [currApp?.app.id]) + trackCreateApp({ appMode: currentCreateAppModeRef.current }) + }, []) const [controlRefreshList, setControlRefreshList] = useState(0) const [controlHideCreateFromTemplatePanel, setControlHideCreateFromTemplatePanel] = useState(0) @@ -83,9 +83,10 @@ const Apps = () => { }) => { hideTryAppPanel() - const { export_data } = await fetchAppDetail( + const { export_data, mode } = await fetchAppDetail( currApp?.app.id as string, ) + currentCreateAppModeRef.current = mode const payload = { mode: DSLImportMode.YAML_CONTENT, yaml_content: export_data, diff --git a/web/app/components/base/amplitude/AmplitudeProvider.tsx b/web/app/components/base/amplitude/AmplitudeProvider.tsx index 00af15e24d..9ba64ceb30 100644 --- a/web/app/components/base/amplitude/AmplitudeProvider.tsx +++ b/web/app/components/base/amplitude/AmplitudeProvider.tsx @@ -5,7 +5,7 @@ import * as amplitude from '@amplitude/analytics-browser' import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser' import * as React from 'react' import { useEffect } from 'react' -import { AMPLITUDE_API_KEY, isAmplitudeEnabled } from '@/config' +import { AMPLITUDE_API_KEY } from '@/config' export type IAmplitudeProps = { sessionReplaySampleRate?: number @@ -54,8 +54,8 @@ const AmplitudeProvider: FC = ({ }) => { useEffect(() => { // Only enable in Saas edition with valid API key - if (!isAmplitudeEnabled) - return + // if (!isAmplitudeEnabled) + // return // Initialize Amplitude amplitude.init(AMPLITUDE_API_KEY, { diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx index 20913cfc7d..e3446086a7 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -218,7 +218,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' }) + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT }) mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => { options.onPending?.() }) @@ -240,8 +240,7 @@ describe('AppList', () => { await waitFor(() => { expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1) expect(mockTrackCreateApp).toHaveBeenCalledWith({ - source: 'explore_template_list', - templateId: 'app-basic-id', + appMode: AppModeEnum.CHAT, }) expect(onSuccess).toHaveBeenCalledTimes(1) }) @@ -315,7 +314,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' }) + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) renderAppList(true) fireEvent.click(screen.getByText('explore.appCard.addToWorkspace')) @@ -333,7 +332,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' }) + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => { options.onSuccess?.() }) @@ -346,8 +345,7 @@ describe('AppList', () => { expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument() }) expect(mockTrackCreateApp).toHaveBeenCalledWith({ - source: 'explore_template_list', - templateId: 'app-basic-id', + appMode: AppModeEnum.CHAT, }) }) @@ -357,7 +355,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' }) + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => { options.onPending?.() }) @@ -403,7 +401,7 @@ describe('AppList', () => { categories: ['Writing'], allList: [createApp()], }; - (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml' }) + (fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml', mode: AppModeEnum.CHAT }) mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => { options.onSuccess?.() }) @@ -416,8 +414,7 @@ describe('AppList', () => { await waitFor(() => { expect(mockTrackCreateApp).toHaveBeenCalledWith({ - source: 'explore_template_preview', - templateId: 'app-basic-id', + appMode: AppModeEnum.CHAT, }) }) }) diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 7eaef06f11..684ab9e267 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -6,7 +6,7 @@ import type { TryAppSelection } from '@/types/try-app' import { useDebounceFn } from 'ahooks' import { useQueryState } from 'nuqs' import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal' import Button from '@/app/components/base/button' @@ -92,7 +92,6 @@ const Apps = ({ const [currApp, setCurrApp] = useState(null) const [isShowCreateModal, setIsShowCreateModal] = useState(false) - const [createAppSource, setCreateAppSource] = useState<'explore_template_list' | 'explore_template_preview'>('explore_template_list') const { handleImportDSL, @@ -103,6 +102,7 @@ const Apps = ({ const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false) const [currentTryApp, setCurrentTryApp] = useState(undefined) + const currentCreateAppModeRef = useRef(null) const isShowTryAppPanel = !!currentTryApp const hideTryAppPanel = useCallback(() => { setCurrentTryApp(undefined) @@ -112,16 +112,14 @@ const Apps = ({ }, []) const handleShowFromTryApp = useCallback(() => { setCurrApp(currentTryApp?.app || null) - setCreateAppSource('explore_template_preview') setIsShowCreateModal(true) }, [currentTryApp?.app]) const trackCurrentCreateApp = useCallback(() => { - const templateId = currApp?.app.id - if (!templateId) + if (!currentCreateAppModeRef.current) return - trackCreateApp({ source: createAppSource, templateId }) - }, [createAppSource, currApp?.app.id]) + trackCreateApp({ appMode: currentCreateAppModeRef.current }) + }, []) const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, @@ -132,9 +130,10 @@ const Apps = ({ }) => { hideTryAppPanel() - const { export_data } = await fetchAppDetail( + const { export_data, mode } = await fetchAppDetail( currApp?.app.id as string, ) + currentCreateAppModeRef.current = mode const payload = { mode: DSLImportMode.YAML_CONTENT, yaml_content: export_data, @@ -240,7 +239,6 @@ const Apps = ({ canCreate={hasEditPermission} onCreate={() => { setCurrApp(app) - setCreateAppSource('explore_template_list') setIsShowCreateModal(true) }} onTry={handleTryApp} diff --git a/web/utils/__tests__/create-app-tracking.spec.ts b/web/utils/__tests__/create-app-tracking.spec.ts index 778a062a60..855f54ebca 100644 --- a/web/utils/__tests__/create-app-tracking.spec.ts +++ b/web/utils/__tests__/create-app-tracking.spec.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import * as amplitude from '@/app/components/base/amplitude' +import { AppModeEnum } from '@/types/app' import { buildCreateAppEventPayload, extractExternalCreateAppAttribution, @@ -42,21 +43,58 @@ describe('create-app-tracking', () => { }) describe('buildCreateAppEventPayload', () => { - it('should build template payloads with template id', () => { + it('should build original payloads with normalized app mode and timestamp', () => { expect(buildCreateAppEventPayload({ - source: 'explore_template_preview', - templateId: 'template-1', - })).toEqual({ - source: 'explore_template_preview', - template_id: 'template-1', + appMode: AppModeEnum.ADVANCED_CHAT, + }, null, new Date(2026, 3, 13, 14, 5, 9))).toEqual({ + source: 'original', + app_mode: 'chatflow', + time: '04-13-14:05:09', + }) + }) + + it('should map agent mode into the canonical app mode bucket', () => { + expect(buildCreateAppEventPayload({ + appMode: AppModeEnum.AGENT_CHAT, + }, null, new Date(2026, 3, 13, 9, 8, 7))).toEqual({ + source: 'original', + app_mode: 'agent', + time: '04-13-09:08:07', + }) + }) + + it('should fold legacy non-agent modes into chatflow', () => { + expect(buildCreateAppEventPayload({ + appMode: AppModeEnum.CHAT, + }, null, new Date(2026, 3, 13, 8, 0, 1))).toEqual({ + source: 'original', + app_mode: 'chatflow', + time: '04-13-08:00:01', + }) + + expect(buildCreateAppEventPayload({ + appMode: AppModeEnum.COMPLETION, + }, null, new Date(2026, 3, 13, 8, 0, 2))).toEqual({ + source: 'original', + app_mode: 'chatflow', + time: '04-13-08:00:02', + }) + }) + + it('should map workflow mode into the workflow bucket', () => { + expect(buildCreateAppEventPayload({ + appMode: AppModeEnum.WORKFLOW, + }, null, new Date(2026, 3, 13, 7, 6, 5))).toEqual({ + source: 'original', + app_mode: 'workflow', + time: '04-13-07:06:05', }) }) it('should prefer external attribution when present', () => { expect(buildCreateAppEventPayload( { - source: 'studio_template_list', - templateId: 'template-2', + appMode: AppModeEnum.WORKFLOW, }, { utmSource: 'linkedin', @@ -76,7 +114,7 @@ describe('create-app-tracking', () => { searchParams: new URLSearchParams('utm_source=newsletter&slug=how-to-build-rag-agent'), }) - trackCreateApp({ source: 'studio_blank' }) + trackCreateApp({ appMode: AppModeEnum.WORKFLOW }) expect(amplitude.trackEvent).toHaveBeenNthCalledWith(1, 'create_app', { source: 'external', @@ -84,10 +122,12 @@ describe('create-app-tracking', () => { utm_campaign: 'how-to-build-rag-agent', }) - trackCreateApp({ source: 'studio_blank' }) + trackCreateApp({ appMode: AppModeEnum.WORKFLOW }) expect(amplitude.trackEvent).toHaveBeenNthCalledWith(2, 'create_app', { - source: 'studio_blank', + source: 'original', + app_mode: 'workflow', + time: expect.stringMatching(/^\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/), }) }) }) diff --git a/web/utils/create-app-tracking.ts b/web/utils/create-app-tracking.ts index b2d377a154..8be63511bb 100644 --- a/web/utils/create-app-tracking.ts +++ b/web/utils/create-app-tracking.ts @@ -1,5 +1,6 @@ import Cookies from 'js-cookie' import { trackEvent } from '@/app/components/base/amplitude' +import { AppModeEnum } from '@/types/app' const CREATE_APP_EXTERNAL_ATTRIBUTION_STORAGE_KEY = 'create_app_external_attribution' @@ -16,13 +17,11 @@ type SearchParamReader = { get: (name: string) => string | null } -type CreateAppSource = 'external' | 'explore_template_list' | 'explore_template_preview' | 'studio_blank' | 'studio_template_list' | 'studio_template_preview' | 'studio_upload' +type OriginalCreateAppMode = 'workflow' | 'chatflow' | 'agent' -type TemplateCreateAppSource = Extract - -type NonTemplateCreateAppSource = Extract - -type TrackCreateAppParams = { source: TemplateCreateAppSource, templateId: string } | { source: NonTemplateCreateAppSource } +type TrackCreateAppParams = { + appMode: AppModeEnum +} type ExternalCreateAppAttribution = { utmSource: typeof EXTERNAL_UTM_SOURCE_MAP[keyof typeof EXTERNAL_UTM_SOURCE_MAP] @@ -69,6 +68,22 @@ const mapExternalUtmSource = (value?: string) => { return EXTERNAL_UTM_SOURCE_MAP[normalized as keyof typeof EXTERNAL_UTM_SOURCE_MAP] } +const padTimeValue = (value: number) => String(value).padStart(2, '0') + +const formatCreateAppTime = (date: Date) => { + return `${padTimeValue(date.getMonth() + 1)}-${padTimeValue(date.getDate())}-${padTimeValue(date.getHours())}:${padTimeValue(date.getMinutes())}:${padTimeValue(date.getSeconds())}` +} + +const mapOriginalCreateAppMode = (appMode: AppModeEnum): OriginalCreateAppMode => { + if (appMode === AppModeEnum.WORKFLOW) + return 'workflow' + + if (appMode === AppModeEnum.AGENT_CHAT) + return 'agent' + + return 'chatflow' +} + export const extractExternalCreateAppAttribution = ({ searchParams, utmInfo, @@ -144,6 +159,7 @@ const resolveCurrentExternalCreateAppAttribution = () => { export const buildCreateAppEventPayload = ( params: TrackCreateAppParams, externalAttribution?: ExternalCreateAppAttribution | null, + currentTime = new Date(), ) => { if (externalAttribution) { return { @@ -153,15 +169,10 @@ export const buildCreateAppEventPayload = ( } satisfies Record } - if ('templateId' in params) { - return { - source: params.source, - template_id: params.templateId, - } satisfies Record - } - return { - source: params.source, + source: 'original', + app_mode: mapOriginalCreateAppMode(params.appMode), + time: formatCreateAppTime(currentTime), } satisfies Record } From 6e75b8d2084dea4d60b0e87ee41ca4aa06e5a417 Mon Sep 17 00:00:00 2001 From: hj24 Date: Mon, 13 Apr 2026 17:35:32 +0800 Subject: [PATCH 034/103] fix: db session expired issue --- api/controllers/console/app/workflow_app_log.py | 4 ++-- api/controllers/console/app/workflow_trigger.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/controllers/console/app/workflow_app_log.py b/api/controllers/console/app/workflow_app_log.py index 3b24c2a402..8ae6a78a62 100644 --- a/api/controllers/console/app/workflow_app_log.py +++ b/api/controllers/console/app/workflow_app_log.py @@ -87,7 +87,7 @@ class WorkflowAppLogApi(Resource): # get paginate workflow app logs workflow_app_service = WorkflowAppService() - with sessionmaker(db.engine).begin() as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs( session=session, app_model=app_model, @@ -124,7 +124,7 @@ class WorkflowArchivedLogApi(Resource): args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore workflow_app_service = WorkflowAppService() - with sessionmaker(db.engine).begin() as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_archive_logs( session=session, app_model=app_model, diff --git a/api/controllers/console/app/workflow_trigger.py b/api/controllers/console/app/workflow_trigger.py index e4a6afae1e..c457684c15 100644 --- a/api/controllers/console/app/workflow_trigger.py +++ b/api/controllers/console/app/workflow_trigger.py @@ -64,7 +64,7 @@ class WebhookTriggerApi(Resource): node_id = args.node_id - with sessionmaker(db.engine).begin() as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: # Get webhook trigger for this app and node webhook_trigger = session.scalar( select(WorkflowWebhookTrigger) @@ -95,7 +95,7 @@ class AppTriggersApi(Resource): assert isinstance(current_user, Account) assert current_user.current_tenant_id is not None - with sessionmaker(db.engine).begin() as session: + with sessionmaker(db.engine, expire_on_commit=False).begin() as session: # Get all triggers for this app using select API triggers = ( session.execute( From 711fe6ba2ce4c776641e6548b3ad8e7d73e8c5bd Mon Sep 17 00:00:00 2001 From: aether <144865106+aether-png@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:33:53 +0530 Subject: [PATCH 035/103] refactor: convert plugin permission if/elif to match/case (#30001) (#35140) --- api/controllers/console/workspace/__init__.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/api/controllers/console/workspace/__init__.py b/api/controllers/console/workspace/__init__.py index 60f712e476..59dd29fdac 100644 --- a/api/controllers/console/workspace/__init__.py +++ b/api/controllers/console/workspace/__init__.py @@ -35,22 +35,24 @@ def plugin_permission_required( return view(*args, **kwargs) if install_required: - if permission.install_permission == TenantPluginPermission.InstallPermission.NOBODY: - raise Forbidden() - if permission.install_permission == TenantPluginPermission.InstallPermission.ADMINS: - if not user.is_admin_or_owner: + match permission.install_permission: + case TenantPluginPermission.InstallPermission.NOBODY: raise Forbidden() - if permission.install_permission == TenantPluginPermission.InstallPermission.EVERYONE: - pass + case TenantPluginPermission.InstallPermission.ADMINS: + if not user.is_admin_or_owner: + raise Forbidden() + case TenantPluginPermission.InstallPermission.EVERYONE: + pass if debug_required: - if permission.debug_permission == TenantPluginPermission.DebugPermission.NOBODY: - raise Forbidden() - if permission.debug_permission == TenantPluginPermission.DebugPermission.ADMINS: - if not user.is_admin_or_owner: + match permission.debug_permission: + case TenantPluginPermission.DebugPermission.NOBODY: raise Forbidden() - if permission.debug_permission == TenantPluginPermission.DebugPermission.EVERYONE: - pass + case TenantPluginPermission.DebugPermission.ADMINS: + if not user.is_admin_or_owner: + raise Forbidden() + case TenantPluginPermission.DebugPermission.EVERYONE: + pass return view(*args, **kwargs) From f7c6270f7400d2de52fae911234c56e19c959fc4 Mon Sep 17 00:00:00 2001 From: HeYinKazune <70251095+HeYin-OS@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:23:29 +0900 Subject: [PATCH 036/103] refactor: use sessionmaker in tool_label_manager.py (#34895) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/tools/tool_label_manager.py | 73 +++++++++--- .../tools/workflow_tools_manage_service.py | 90 ++++++++------ .../core/tools/test_tool_label_manager.py | 111 ++++++++++++++++-- 3 files changed, 209 insertions(+), 65 deletions(-) diff --git a/api/core/tools/tool_label_manager.py b/api/core/tools/tool_label_manager.py index 58190d1089..d8969a3391 100644 --- a/api/core/tools/tool_label_manager.py +++ b/api/core/tools/tool_label_manager.py @@ -1,4 +1,5 @@ from sqlalchemy import delete, select +from sqlalchemy.orm import Session, sessionmaker from core.tools.__base.tool_provider import ToolProviderController from core.tools.builtin_tool.provider import BuiltinToolProviderController @@ -19,10 +20,18 @@ class ToolLabelManager: return list(set(tool_labels)) @classmethod - def update_tool_labels(cls, controller: ToolProviderController, labels: list[str]): + def update_tool_labels( + cls, controller: ToolProviderController, labels: list[str], session: Session | None = None + ) -> None: """ Update tool labels + + :param controller: tool provider controller + :param labels: list of tool labels + :param session: database session, if None, a new session will be created + :return: None """ + labels = cls.filter_tool_labels(labels) if isinstance(controller, ApiToolProviderController | WorkflowToolProviderController): @@ -30,26 +39,46 @@ class ToolLabelManager: else: raise ValueError("Unsupported tool type") + if session is not None: + cls._update_tool_labels_logics(session, provider_id, controller, labels) + else: + with sessionmaker(db.engine).begin() as _session: + cls._update_tool_labels_logics(_session, provider_id, controller, labels) + + @classmethod + def _update_tool_labels_logics( + cls, session: Session, provider_id: str, controller: ToolProviderController, labels: list[str] + ) -> None: + """ + Update tool labels logics + + :param session: database session + :param provider_id: tool provider ID + :param controller: tool provider controller + :param labels: list of tool labels + :return: None + """ + # delete old labels - db.session.execute(delete(ToolLabelBinding).where(ToolLabelBinding.tool_id == provider_id)) + _ = session.execute( + delete(ToolLabelBinding).where( + ToolLabelBinding.tool_id == provider_id, ToolLabelBinding.tool_type == controller.provider_type + ) + ) # insert new labels for label in labels: - db.session.add( - ToolLabelBinding( - tool_id=provider_id, - tool_type=controller.provider_type, - label_name=label, - ) - ) - - db.session.commit() + session.add(ToolLabelBinding(tool_id=provider_id, tool_type=controller.provider_type, label_name=label)) @classmethod def get_tool_labels(cls, controller: ToolProviderController) -> list[str]: """ Get tool labels + + :param controller: tool provider controller + :return: list of tool labels (str) """ + if isinstance(controller, ApiToolProviderController | WorkflowToolProviderController): provider_id = controller.provider_id elif isinstance(controller, BuiltinToolProviderController): @@ -60,9 +89,11 @@ class ToolLabelManager: ToolLabelBinding.tool_id == provider_id, ToolLabelBinding.tool_type == controller.provider_type, ) - labels = db.session.scalars(stmt).all() - return list(labels) + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + labels: list[str] = list(_session.scalars(stmt).all()) + + return labels @classmethod def get_tools_labels(cls, tool_providers: list[ToolProviderController]) -> dict[str, list[str]]: @@ -78,16 +109,22 @@ class ToolLabelManager: if not tool_providers: return {} + provider_ids: list[str] = [] + provider_types: set[str] = set() + for controller in tool_providers: if not isinstance(controller, ApiToolProviderController | WorkflowToolProviderController): raise ValueError("Unsupported tool type") - - provider_ids = [] - for controller in tool_providers: - assert isinstance(controller, ApiToolProviderController | WorkflowToolProviderController) provider_ids.append(controller.provider_id) + provider_types.add(controller.provider_type) - labels = db.session.scalars(select(ToolLabelBinding).where(ToolLabelBinding.tool_id.in_(provider_ids))).all() + labels: list[ToolLabelBinding] = [] + + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + stmt = select(ToolLabelBinding).where( + ToolLabelBinding.tool_id.in_(provider_ids), ToolLabelBinding.tool_type.in_(list(provider_types)) + ) + labels = list(_session.scalars(stmt).all()) tool_labels: dict[str, list[str]] = {label.tool_id: [] for label in labels} diff --git a/api/services/tools/workflow_tools_manage_service.py b/api/services/tools/workflow_tools_manage_service.py index 779f7c4511..be2572b592 100644 --- a/api/services/tools/workflow_tools_manage_service.py +++ b/api/services/tools/workflow_tools_manage_service.py @@ -139,62 +139,82 @@ class WorkflowToolManageService: :param labels: labels :return: the updated tool """ - # check if the name is unique - existing_workflow_tool_provider = db.session.scalar( - select(WorkflowToolProvider) - .where( - WorkflowToolProvider.tenant_id == tenant_id, - WorkflowToolProvider.name == name, - WorkflowToolProvider.id != workflow_tool_id, - ) - .limit(1) - ) + existing_workflow_tool_provider: WorkflowToolProvider | None = None + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + # query if the name exists for other tools + existing_workflow_tool_provider = _session.scalar( + select(WorkflowToolProvider) + .where( + WorkflowToolProvider.tenant_id == tenant_id, + WorkflowToolProvider.name == name, + WorkflowToolProvider.id != workflow_tool_id, + ) + .limit(1) + ) + + # if the name exists raise error if existing_workflow_tool_provider is not None: raise ValueError(f"Tool with name {name} already exists") - workflow_tool_provider: WorkflowToolProvider | None = db.session.scalar( - select(WorkflowToolProvider) - .where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id) - .limit(1) - ) + # query the workflow tool provider + workflow_tool_provider: WorkflowToolProvider | None = None + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + workflow_tool_provider = _session.scalar( + select(WorkflowToolProvider) + .where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id) + .limit(1) + ) + # if not found raise error if workflow_tool_provider is None: raise ValueError(f"Tool {workflow_tool_id} not found") - app: App | None = db.session.scalar( - select(App).where(App.id == workflow_tool_provider.app_id, App.tenant_id == tenant_id).limit(1) - ) + # query the app + app: App | None = None + with sessionmaker(db.engine, expire_on_commit=False).begin() as _session: + app = _session.scalar( + select(App).where(App.id == workflow_tool_provider.app_id, App.tenant_id == tenant_id).limit(1) + ) + # if not found raise error if app is None: raise ValueError(f"App {workflow_tool_provider.app_id} not found") + # query the workflow workflow: Workflow | None = app.workflow + + # if not found raise error if workflow is None: raise ValueError(f"Workflow not found for app {workflow_tool_provider.app_id}") + # check if workflow configuration is synced WorkflowToolConfigurationUtils.ensure_no_human_input_nodes(workflow.graph_dict) - workflow_tool_provider.name = name - workflow_tool_provider.label = label - workflow_tool_provider.icon = json.dumps(icon) - workflow_tool_provider.description = description - workflow_tool_provider.parameter_configuration = json.dumps([p.model_dump() for p in parameters]) - workflow_tool_provider.privacy_policy = privacy_policy - workflow_tool_provider.version = workflow.version - workflow_tool_provider.updated_at = datetime.now() + with sessionmaker(db.engine).begin() as _session: + _session.add(workflow_tool_provider) - try: - WorkflowToolProviderController.from_db(workflow_tool_provider) - except Exception as e: - raise ValueError(str(e)) + # update workflow tool provider + workflow_tool_provider.name = name + workflow_tool_provider.label = label + workflow_tool_provider.icon = json.dumps(icon) + workflow_tool_provider.description = description + workflow_tool_provider.parameter_configuration = json.dumps([p.model_dump() for p in parameters]) + workflow_tool_provider.privacy_policy = privacy_policy + workflow_tool_provider.version = workflow.version + workflow_tool_provider.updated_at = datetime.now() - db.session.commit() + try: + WorkflowToolProviderController.from_db(workflow_tool_provider) + except Exception as e: + raise ValueError(str(e)) - if labels is not None: - ToolLabelManager.update_tool_labels( - ToolTransformService.workflow_provider_to_controller(workflow_tool_provider), labels - ) + if labels is not None: + ToolLabelManager.update_tool_labels( + ToolTransformService.workflow_provider_to_controller(workflow_tool_provider), + labels, + session=_session, + ) return {"result": "success"} diff --git a/api/tests/unit_tests/core/tools/test_tool_label_manager.py b/api/tests/unit_tests/core/tools/test_tool_label_manager.py index 8c0e7e9419..e13f430f9b 100644 --- a/api/tests/unit_tests/core/tools/test_tool_label_manager.py +++ b/api/tests/unit_tests/core/tools/test_tool_label_manager.py @@ -2,7 +2,7 @@ from __future__ import annotations from types import SimpleNamespace from typing import Any -from unittest.mock import PropertyMock, patch +from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -12,11 +12,13 @@ from core.tools.tool_label_manager import ToolLabelManager from core.tools.workflow_as_tool.provider import WorkflowToolProviderController +# Create a mock class for testing abstract/base classes class _ConcreteBuiltinToolProviderController(BuiltinToolProviderController): def _validate_credentials(self, user_id: str, credentials: dict[str, Any]): return None +# Factory function to create a "lightweight" controller for testing def _api_controller(provider_id: str = "api-1") -> ApiToolProviderController: controller = object.__new__(ApiToolProviderController) controller.provider_id = provider_id @@ -29,6 +31,7 @@ def _workflow_controller(provider_id: str = "wf-1") -> WorkflowToolProviderContr return controller +# Test pure logic: filtering and deduplication def test_tool_label_manager_filter_tool_labels(): filtered = ToolLabelManager.filter_tool_labels(["search", "search", "invalid", "news"]) assert set(filtered) == {"search", "news"} @@ -36,22 +39,68 @@ def test_tool_label_manager_filter_tool_labels(): def test_tool_label_manager_update_tool_labels_db(): + """ + Test the database update logic for tool labels. + Focus: Verify that labels are filtered, de-duplicated, and safely handled within a database session. + """ + # 1. Setup expected data from the controller controller = _api_controller("api-1") - with patch("core.tools.tool_label_manager.db") as mock_db: + expected_id = controller.provider_id + expected_type = controller.provider_type + + # 2. Patching External Dependencies + # - We patch 'db' to prevent Flask from trying to access a real database. + # - We patch 'sessionmaker' to intercept and control the creation of SQLAlchemy sessions. + with ( + patch("core.tools.tool_label_manager.db"), + patch("core.tools.tool_label_manager.sessionmaker") as mock_sessionmaker, + ): + # 3. Constructing the "Mocking Chain" + # In the business logic, we use: with sessionmaker(db.engine).begin() as _session: + # We need to link our 'mock_session' to the end of this complex context manager chain: + # Step A: sessionmaker(db.engine) -> returns an object (mock_sessionmaker.return_value) + # Step B: .begin() -> returns a context manager (begin.return_value) + # Step C: with ... as _session: -> calls __enter__(), and _session gets the __enter__.return_value + mock_session = MagicMock() + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + + # 4. Trigger the logic under test + # Input: ["search", "search", "invalid"] + # Logic: + # - "invalid" should be filtered out (not in default_tool_label_name_list). + # - The duplicate "search" should be merged (unique labels). ToolLabelManager.update_tool_labels(controller, ["search", "search", "invalid"]) - mock_db.session.execute.assert_called_once() - # only one valid unique label should be inserted. - assert mock_db.session.add.call_count == 1 - mock_db.session.commit.assert_called_once() + # 5. Behavior Assertion: DELETE operation + # Verify that the manager first attempts to clear existing labels for this specific tool. + # This ensures the update is idempotent. + mock_session.execute.assert_called_once() + + # 6. Behavior Assertion: INSERT operation + # Verify that only ONE valid label ("search") was added after filtering and deduplication. + # If call_count == 1, it proves filter_tool_labels() worked as expected. + assert mock_session.add.call_count == 1 + + # 7. State Assertion: Data Integrity & Isolation + # Inspect the actual object passed to session.add() to ensure it has correct properties. + # This confirms that the data isolation (tool_id + tool_type) we refactored is active. + call_args = mock_session.add.call_args + added_label = call_args[0][0] # Retrieve the ToolLabelBinding instance + + assert added_label.label_name == "search", "The label name should be 'search' after filtering." + assert added_label.tool_id == expected_id, "The tool_id must match the provider_id for correct binding." + assert added_label.tool_type == expected_type, "Isolation failed: tool_type must be verified during update." +# Test error handling def test_tool_label_manager_update_tool_labels_unsupported(): with pytest.raises(ValueError, match="Unsupported tool type"): ToolLabelManager.update_tool_labels(object(), ["search"]) # type: ignore[arg-type] +# Test retrieval logic def test_tool_label_manager_get_tool_labels_for_builtin_and_db(): + # Mocking a property (@property) using PropertyMock with patch.object( _ConcreteBuiltinToolProviderController, "tool_labels", @@ -62,29 +111,67 @@ def test_tool_label_manager_get_tool_labels_for_builtin_and_db(): assert ToolLabelManager.get_tool_labels(builtin) == ["search", "news"] api = _api_controller("api-1") - with patch("core.tools.tool_label_manager.db") as mock_db: - mock_db.session.scalars.return_value.all.return_value = ["search", "news"] - labels = ToolLabelManager.get_tool_labels(api) - assert labels == ["search", "news"] + with ( + patch("core.tools.tool_label_manager.db"), + patch("core.tools.tool_label_manager.sessionmaker") as mock_sessionmaker, + ): + mock_session = MagicMock() + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + # Inject mock data into the query result: session.scalars(stmt).all() + mock_session.scalars.return_value.all.return_value = ["search", "news"] + + labels = ToolLabelManager.get_tool_labels(api) + assert labels == ["search", "news"] + + +def test_tool_label_manager_get_tool_labels_unsupported(): + """ + Negative Test: Ensure get_tool_labels raises ValueError for unsupported controller types. + This protects the internal API contract against accidental regressions during refactoring. + """ + # Passing a generic object() which doesn't match Api, Workflow, or Builtin controllers. with pytest.raises(ValueError, match="Unsupported tool type"): ToolLabelManager.get_tool_labels(object()) # type: ignore[arg-type] +# Test batch processing and mapping def test_tool_label_manager_get_tools_labels_batch(): assert ToolLabelManager.get_tools_labels([]) == {} api = _api_controller("api-1") wf = _workflow_controller("wf-1") + + # SimpleNamespace is a quick way to simulate SQLAlchemy row objects records = [ SimpleNamespace(tool_id="api-1", label_name="search"), SimpleNamespace(tool_id="api-1", label_name="news"), SimpleNamespace(tool_id="wf-1", label_name="utilities"), ] - with patch("core.tools.tool_label_manager.db") as mock_db: - mock_db.session.scalars.return_value.all.return_value = records + + with ( + patch("core.tools.tool_label_manager.db"), + patch("core.tools.tool_label_manager.sessionmaker") as mock_sessionmaker, + ): + mock_session = MagicMock() + mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session + + # Simulating the batch query result + mock_session.scalars.return_value.all.return_value = records + labels = ToolLabelManager.get_tools_labels([api, wf]) + + # Verify the final dictionary mapping assert labels == {"api-1": ["search", "news"], "wf-1": ["utilities"]} + +def test_tool_label_manager_get_tools_labels_unsupported(): + """ + Negative Test: Ensure get_tools_labels raises ValueError if the list contains + unsupported controller types, even alongside valid ones. + """ + api = _api_controller("api-1") + + # Passing a list with one valid controller and one invalid object() with pytest.raises(ValueError, match="Unsupported tool type"): ToolLabelManager.get_tools_labels([api, object()]) # type: ignore[list-item] From 62bb830338adaf14f1acde10758f5f0027294f0d Mon Sep 17 00:00:00 2001 From: aether <144865106+aether-png@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:16:58 +0530 Subject: [PATCH 037/103] refactor: convert InvokeFrom if/elif to match/case (#35143) --- .../apps/workflow/generate_task_pipeline.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/api/core/app/apps/workflow/generate_task_pipeline.py b/api/core/app/apps/workflow/generate_task_pipeline.py index f1b8b08eaa..96387133b1 100644 --- a/api/core/app/apps/workflow/generate_task_pipeline.py +++ b/api/core/app/apps/workflow/generate_task_pipeline.py @@ -682,15 +682,16 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): def _save_workflow_app_log(self, *, session: Session, workflow_run_id: str | None): invoke_from = self._application_generate_entity.invoke_from - if invoke_from == InvokeFrom.SERVICE_API: - created_from = WorkflowAppLogCreatedFrom.SERVICE_API - elif invoke_from == InvokeFrom.EXPLORE: - created_from = WorkflowAppLogCreatedFrom.INSTALLED_APP - elif invoke_from == InvokeFrom.WEB_APP: - created_from = WorkflowAppLogCreatedFrom.WEB_APP - else: - # not save log for debugging - return + match invoke_from: + case InvokeFrom.SERVICE_API: + created_from = WorkflowAppLogCreatedFrom.SERVICE_API + case InvokeFrom.EXPLORE: + created_from = WorkflowAppLogCreatedFrom.INSTALLED_APP + case InvokeFrom.WEB_APP: + created_from = WorkflowAppLogCreatedFrom.WEB_APP + case InvokeFrom.DEBUGGER | InvokeFrom.TRIGGER | InvokeFrom.PUBLISHED_PIPELINE | InvokeFrom.VALIDATION: + # not save log for debugging + return if not workflow_run_id: return From 52711906832fbac9013ec8fdbd210243647ceee2 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Tue, 14 Apr 2026 15:47:10 +0800 Subject: [PATCH 038/103] add new endpoint for every new cluster --- .../tidb_on_qdrant/tidb_on_qdrant_vector.py | 7 +++- .../vdb/tidb_on_qdrant/tidb_service.py | 34 +++++++++++++++++++ api/models/dataset.py | 1 + api/schedule/create_tidb_serverless_task.py | 1 + 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_on_qdrant_vector.py b/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_on_qdrant_vector.py index 605cc5a08f..c40d71bf11 100644 --- a/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_on_qdrant_vector.py +++ b/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_on_qdrant_vector.py @@ -439,6 +439,7 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory): idle_tidb_auth_binding.active = True idle_tidb_auth_binding.tenant_id = dataset.tenant_id db.session.commit() + tidb_auth_binding = idle_tidb_auth_binding TIDB_ON_QDRANT_API_KEY = f"{idle_tidb_auth_binding.account}:{idle_tidb_auth_binding.password}" else: new_cluster = TidbService.create_tidb_serverless_cluster( @@ -454,16 +455,20 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory): cluster_name=new_cluster["cluster_name"], account=new_cluster["account"], password=new_cluster["password"], + qdrant_endpoint=new_cluster.get("qdrant_endpoint"), tenant_id=dataset.tenant_id, active=True, status=TidbAuthBindingStatus.ACTIVE, ) db.session.add(new_tidb_auth_binding) db.session.commit() + tidb_auth_binding = new_tidb_auth_binding TIDB_ON_QDRANT_API_KEY = f"{new_tidb_auth_binding.account}:{new_tidb_auth_binding.password}" else: TIDB_ON_QDRANT_API_KEY = f"{tidb_auth_binding.account}:{tidb_auth_binding.password}" + qdrant_url = (tidb_auth_binding.qdrant_endpoint if tidb_auth_binding else None) or dify_config.TIDB_ON_QDRANT_URL or "" + if dataset.index_struct_dict: class_prefix: str = dataset.index_struct_dict["vector_store"]["class_prefix"] collection_name = class_prefix @@ -478,7 +483,7 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory): collection_name=collection_name, group_id=dataset.id, config=TidbOnQdrantConfig( - endpoint=dify_config.TIDB_ON_QDRANT_URL or "", + endpoint=qdrant_url, api_key=TIDB_ON_QDRANT_API_KEY, root_path=str(config.root_path), timeout=dify_config.TIDB_ON_QDRANT_CLIENT_TIMEOUT, diff --git a/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py b/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py index 37114be6e7..f617fb081e 100644 --- a/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py +++ b/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py @@ -20,6 +20,33 @@ _tidb_http_client: httpx.Client = get_pooled_http_client( class TidbService: + @staticmethod + def fetch_qdrant_endpoint( + api_url: str, public_key: str, private_key: str, cluster_id: str + ) -> str | None: + """Fetch the qdrant endpoint for a cluster by calling the Get Cluster API. + + The Get Cluster response contains ``status.connection_strings.standard.host`` + (e.g. ``gateway01.xx.tidbcloud.com``). We prepend ``qdrant-`` and wrap it + as an ``https://`` URL. + """ + try: + cluster_response = TidbService.get_tidb_serverless_cluster( + api_url, public_key, private_key, cluster_id + ) + if not cluster_response: + return None + # v1beta: status.connection_strings.standard.host + status = cluster_response.get("status") or {} + connection_strings = status.get("connection_strings") or {} + standard = connection_strings.get("standard") or {} + host = standard.get("host") + if host: + return f"https://qdrant-{host}" + except Exception: + pass + return None + @staticmethod def create_tidb_serverless_cluster( project_id: str, api_url: str, iam_url: str, public_key: str, private_key: str, region: str @@ -70,11 +97,15 @@ class TidbService: cluster_response = TidbService.get_tidb_serverless_cluster(api_url, public_key, private_key, cluster_id) if cluster_response["state"] == "ACTIVE": user_prefix = cluster_response["userPrefix"] + qdrant_endpoint = TidbService.fetch_qdrant_endpoint( + api_url, public_key, private_key, cluster_id + ) return { "cluster_id": cluster_id, "cluster_name": display_name, "account": f"{user_prefix}.root", "password": password, + "qdrant_endpoint": qdrant_endpoint, } time.sleep(30) # wait 30 seconds before retrying retry_count += 1 @@ -253,6 +284,9 @@ class TidbService: "cluster_name": item["displayName"], "account": "root", "password": cached_password.decode("utf-8"), + "qdrant_endpoint": TidbService.fetch_qdrant_endpoint( + api_url, public_key, private_key, item["clusterId"] + ), } cluster_infos.append(cluster_info) return cluster_infos diff --git a/api/models/dataset.py b/api/models/dataset.py index 97604848af..14dfcea5de 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -1250,6 +1250,7 @@ class TidbAuthBinding(TypeBase): ) account: Mapped[str] = mapped_column(String(255), nullable=False) password: Mapped[str] = mapped_column(String(255), nullable=False) + qdrant_endpoint: Mapped[str | None] = mapped_column(String(512), nullable=True, default=None) created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.current_timestamp(), init=False ) diff --git a/api/schedule/create_tidb_serverless_task.py b/api/schedule/create_tidb_serverless_task.py index 6ceb3ef856..511de002d2 100644 --- a/api/schedule/create_tidb_serverless_task.py +++ b/api/schedule/create_tidb_serverless_task.py @@ -57,6 +57,7 @@ def create_clusters(batch_size): cluster_name=new_cluster["cluster_name"], account=new_cluster["account"], password=new_cluster["password"], + qdrant_endpoint=new_cluster.get("qdrant_endpoint"), active=False, status=TidbAuthBindingStatus.CREATING, ) From 1f4e039fc1600e368be0e803c9e313fb95f1ab32 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Tue, 14 Apr 2026 15:52:41 +0800 Subject: [PATCH 039/103] add migration --- ...d_qdrant_endpoint_to_tidb_auth_bindings.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 api/migrations/versions/2026_04_14_1500-8574b23a38fd_add_qdrant_endpoint_to_tidb_auth_bindings.py diff --git a/api/migrations/versions/2026_04_14_1500-8574b23a38fd_add_qdrant_endpoint_to_tidb_auth_bindings.py b/api/migrations/versions/2026_04_14_1500-8574b23a38fd_add_qdrant_endpoint_to_tidb_auth_bindings.py new file mode 100644 index 0000000000..0e188ec080 --- /dev/null +++ b/api/migrations/versions/2026_04_14_1500-8574b23a38fd_add_qdrant_endpoint_to_tidb_auth_bindings.py @@ -0,0 +1,26 @@ +"""add qdrant_endpoint to tidb_auth_bindings + +Revision ID: 8574b23a38fd +Revises: 6b5f9f8b1a2c +Create Date: 2026-04-14 15:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "8574b23a38fd" +down_revision = "6b5f9f8b1a2c" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("tidb_auth_bindings", schema=None) as batch_op: + batch_op.add_column(sa.Column("qdrant_endpoint", sa.String(length=512), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("tidb_auth_bindings", schema=None) as batch_op: + batch_op.drop_column("qdrant_endpoint") From 173e0d6f35f37eaad58e7d9beeb6ce571dfd1b4b Mon Sep 17 00:00:00 2001 From: bohdansolovie <153934212+bohdansolovie@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:56:07 -0400 Subject: [PATCH 040/103] test: migrate clean_dataset integration tests to SQLAlchemy 2.0 APIs (#35146) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../tasks/test_clean_dataset_task.py | 147 ++++++++++++------ 1 file changed, 98 insertions(+), 49 deletions(-) diff --git a/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py b/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py index 1dd37fbc92..32bc2fc0bd 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py @@ -16,6 +16,7 @@ from unittest.mock import MagicMock, patch import pytest from faker import Faker +from sqlalchemy import delete, select from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType @@ -52,18 +53,18 @@ class TestCleanDatasetTask: from extensions.ext_redis import redis_client # Clear all test data using the provided session fixture - db_session_with_containers.query(DatasetMetadataBinding).delete() - db_session_with_containers.query(DatasetMetadata).delete() - db_session_with_containers.query(AppDatasetJoin).delete() - db_session_with_containers.query(DatasetQuery).delete() - db_session_with_containers.query(DatasetProcessRule).delete() - db_session_with_containers.query(DocumentSegment).delete() - db_session_with_containers.query(Document).delete() - db_session_with_containers.query(Dataset).delete() - db_session_with_containers.query(UploadFile).delete() - db_session_with_containers.query(TenantAccountJoin).delete() - db_session_with_containers.query(Tenant).delete() - db_session_with_containers.query(Account).delete() + db_session_with_containers.execute(delete(DatasetMetadataBinding)) + db_session_with_containers.execute(delete(DatasetMetadata)) + db_session_with_containers.execute(delete(AppDatasetJoin)) + db_session_with_containers.execute(delete(DatasetQuery)) + db_session_with_containers.execute(delete(DatasetProcessRule)) + db_session_with_containers.execute(delete(DocumentSegment)) + db_session_with_containers.execute(delete(Document)) + db_session_with_containers.execute(delete(Dataset)) + db_session_with_containers.execute(delete(UploadFile)) + db_session_with_containers.execute(delete(TenantAccountJoin)) + db_session_with_containers.execute(delete(Tenant)) + db_session_with_containers.execute(delete(Account)) db_session_with_containers.commit() # Clear Redis cache @@ -302,28 +303,40 @@ class TestCleanDatasetTask: # Verify results # Check that dataset-related data was cleaned up - documents = db_session_with_containers.query(Document).filter_by(dataset_id=dataset.id).all() + documents = db_session_with_containers.scalars(select(Document).where(Document.dataset_id == dataset.id)).all() assert len(documents) == 0 - segments = db_session_with_containers.query(DocumentSegment).filter_by(dataset_id=dataset.id).all() + segments = db_session_with_containers.scalars( + select(DocumentSegment).where(DocumentSegment.dataset_id == dataset.id) + ).all() assert len(segments) == 0 # Check that metadata and bindings were cleaned up - metadata = db_session_with_containers.query(DatasetMetadata).filter_by(dataset_id=dataset.id).all() + metadata = db_session_with_containers.scalars( + select(DatasetMetadata).where(DatasetMetadata.dataset_id == dataset.id) + ).all() assert len(metadata) == 0 - bindings = db_session_with_containers.query(DatasetMetadataBinding).filter_by(dataset_id=dataset.id).all() + bindings = db_session_with_containers.scalars( + select(DatasetMetadataBinding).where(DatasetMetadataBinding.dataset_id == dataset.id) + ).all() assert len(bindings) == 0 # Check that process rules and queries were cleaned up - process_rules = db_session_with_containers.query(DatasetProcessRule).filter_by(dataset_id=dataset.id).all() + process_rules = db_session_with_containers.scalars( + select(DatasetProcessRule).where(DatasetProcessRule.dataset_id == dataset.id) + ).all() assert len(process_rules) == 0 - queries = db_session_with_containers.query(DatasetQuery).filter_by(dataset_id=dataset.id).all() + queries = db_session_with_containers.scalars( + select(DatasetQuery).where(DatasetQuery.dataset_id == dataset.id) + ).all() assert len(queries) == 0 # Check that app dataset joins were cleaned up - app_joins = db_session_with_containers.query(AppDatasetJoin).filter_by(dataset_id=dataset.id).all() + app_joins = db_session_with_containers.scalars( + select(AppDatasetJoin).where(AppDatasetJoin.dataset_id == dataset.id) + ).all() assert len(app_joins) == 0 # Verify index processor was called @@ -414,24 +427,32 @@ class TestCleanDatasetTask: # Verify results # Check that all documents were deleted - remaining_documents = db_session_with_containers.query(Document).filter_by(dataset_id=dataset.id).all() + remaining_documents = db_session_with_containers.scalars( + select(Document).where(Document.dataset_id == dataset.id) + ).all() assert len(remaining_documents) == 0 # Check that all segments were deleted - remaining_segments = db_session_with_containers.query(DocumentSegment).filter_by(dataset_id=dataset.id).all() + remaining_segments = db_session_with_containers.scalars( + select(DocumentSegment).where(DocumentSegment.dataset_id == dataset.id) + ).all() assert len(remaining_segments) == 0 # Check that all upload files were deleted - remaining_files = db_session_with_containers.query(UploadFile).where(UploadFile.id.in_(upload_file_ids)).all() + remaining_files = db_session_with_containers.scalars( + select(UploadFile).where(UploadFile.id.in_(upload_file_ids)) + ).all() assert len(remaining_files) == 0 # Check that metadata and bindings were cleaned up - remaining_metadata = db_session_with_containers.query(DatasetMetadata).filter_by(dataset_id=dataset.id).all() + remaining_metadata = db_session_with_containers.scalars( + select(DatasetMetadata).where(DatasetMetadata.dataset_id == dataset.id) + ).all() assert len(remaining_metadata) == 0 - remaining_bindings = ( - db_session_with_containers.query(DatasetMetadataBinding).filter_by(dataset_id=dataset.id).all() - ) + remaining_bindings = db_session_with_containers.scalars( + select(DatasetMetadataBinding).where(DatasetMetadataBinding.dataset_id == dataset.id) + ).all() assert len(remaining_bindings) == 0 # Verify index processor was called @@ -485,12 +506,14 @@ class TestCleanDatasetTask: # Check that all data was cleaned up - remaining_documents = db_session_with_containers.query(Document).filter_by(dataset_id=dataset.id).all() + remaining_documents = db_session_with_containers.scalars( + select(Document).where(Document.dataset_id == dataset.id) + ).all() assert len(remaining_documents) == 0 - remaining_segments = ( - db_session_with_containers.query(DocumentSegment).filter_by(dataset_id=dataset.id).all() - ) + remaining_segments = db_session_with_containers.scalars( + select(DocumentSegment).where(DocumentSegment.dataset_id == dataset.id) + ).all() assert len(remaining_segments) == 0 # Recreate data for next test case @@ -538,11 +561,15 @@ class TestCleanDatasetTask: # Verify results - even with vector cleanup failure, documents and segments should be deleted # Check that documents were still deleted despite vector cleanup failure - remaining_documents = db_session_with_containers.query(Document).filter_by(dataset_id=dataset.id).all() + remaining_documents = db_session_with_containers.scalars( + select(Document).where(Document.dataset_id == dataset.id) + ).all() assert len(remaining_documents) == 0 # Check that segments were still deleted despite vector cleanup failure - remaining_segments = db_session_with_containers.query(DocumentSegment).filter_by(dataset_id=dataset.id).all() + remaining_segments = db_session_with_containers.scalars( + select(DocumentSegment).where(DocumentSegment.dataset_id == dataset.id) + ).all() assert len(remaining_segments) == 0 # Verify that index processor was called and failed @@ -622,18 +649,22 @@ class TestCleanDatasetTask: # Verify results # Check that all documents were deleted - remaining_documents = db_session_with_containers.query(Document).filter_by(dataset_id=dataset.id).all() + remaining_documents = db_session_with_containers.scalars( + select(Document).where(Document.dataset_id == dataset.id) + ).all() assert len(remaining_documents) == 0 # Check that all segments were deleted - remaining_segments = db_session_with_containers.query(DocumentSegment).filter_by(dataset_id=dataset.id).all() + remaining_segments = db_session_with_containers.scalars( + select(DocumentSegment).where(DocumentSegment.dataset_id == dataset.id) + ).all() assert len(remaining_segments) == 0 # Check that all image files were deleted from database image_file_ids = [f.id for f in image_files] - remaining_image_files = ( - db_session_with_containers.query(UploadFile).where(UploadFile.id.in_(image_file_ids)).all() - ) + remaining_image_files = db_session_with_containers.scalars( + select(UploadFile).where(UploadFile.id.in_(image_file_ids)) + ).all() assert len(remaining_image_files) == 0 # Verify that storage.delete was called for each image file @@ -738,24 +769,32 @@ class TestCleanDatasetTask: # Verify results # Check that all documents were deleted - remaining_documents = db_session_with_containers.query(Document).filter_by(dataset_id=dataset.id).all() + remaining_documents = db_session_with_containers.scalars( + select(Document).where(Document.dataset_id == dataset.id) + ).all() assert len(remaining_documents) == 0 # Check that all segments were deleted - remaining_segments = db_session_with_containers.query(DocumentSegment).filter_by(dataset_id=dataset.id).all() + remaining_segments = db_session_with_containers.scalars( + select(DocumentSegment).where(DocumentSegment.dataset_id == dataset.id) + ).all() assert len(remaining_segments) == 0 # Check that all upload files were deleted - remaining_files = db_session_with_containers.query(UploadFile).where(UploadFile.id.in_(upload_file_ids)).all() + remaining_files = db_session_with_containers.scalars( + select(UploadFile).where(UploadFile.id.in_(upload_file_ids)) + ).all() assert len(remaining_files) == 0 # Check that all metadata and bindings were deleted - remaining_metadata = db_session_with_containers.query(DatasetMetadata).filter_by(dataset_id=dataset.id).all() + remaining_metadata = db_session_with_containers.scalars( + select(DatasetMetadata).where(DatasetMetadata.dataset_id == dataset.id) + ).all() assert len(remaining_metadata) == 0 - remaining_bindings = ( - db_session_with_containers.query(DatasetMetadataBinding).filter_by(dataset_id=dataset.id).all() - ) + remaining_bindings = db_session_with_containers.scalars( + select(DatasetMetadataBinding).where(DatasetMetadataBinding.dataset_id == dataset.id) + ).all() assert len(remaining_bindings) == 0 # Verify performance expectations @@ -826,7 +865,9 @@ class TestCleanDatasetTask: # Check that upload file was still deleted from database despite storage failure # Note: When storage operations fail, the upload file may not be deleted # This demonstrates that the cleanup process continues even with storage errors - remaining_files = db_session_with_containers.query(UploadFile).filter_by(id=upload_file.id).all() + remaining_files = db_session_with_containers.scalars( + select(UploadFile).where(UploadFile.id == upload_file.id) + ).all() # The upload file should still be deleted from the database even if storage cleanup fails # However, this depends on the specific implementation of clean_dataset_task if len(remaining_files) > 0: @@ -976,19 +1017,27 @@ class TestCleanDatasetTask: # Verify results # Check that all documents were deleted - remaining_documents = db_session_with_containers.query(Document).filter_by(dataset_id=dataset.id).all() + remaining_documents = db_session_with_containers.scalars( + select(Document).where(Document.dataset_id == dataset.id) + ).all() assert len(remaining_documents) == 0 # Check that all segments were deleted - remaining_segments = db_session_with_containers.query(DocumentSegment).filter_by(dataset_id=dataset.id).all() + remaining_segments = db_session_with_containers.scalars( + select(DocumentSegment).where(DocumentSegment.dataset_id == dataset.id) + ).all() assert len(remaining_segments) == 0 # Check that all upload files were deleted - remaining_files = db_session_with_containers.query(UploadFile).filter_by(id=upload_file_id).all() + remaining_files = db_session_with_containers.scalars( + select(UploadFile).where(UploadFile.id == upload_file_id) + ).all() assert len(remaining_files) == 0 # Check that all metadata was deleted - remaining_metadata = db_session_with_containers.query(DatasetMetadata).filter_by(dataset_id=dataset.id).all() + remaining_metadata = db_session_with_containers.scalars( + select(DatasetMetadata).where(DatasetMetadata.dataset_id == dataset.id) + ).all() assert len(remaining_metadata) == 0 # Verify that storage.delete was called From a951cc996b234660c2498d2c0a093a589ab0c1ff Mon Sep 17 00:00:00 2001 From: bohdansolovie <153934212+bohdansolovie@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:56:11 -0400 Subject: [PATCH 041/103] test: migrate document indexing task tests to SQLAlchemy 2.0 select API (#35145) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../tasks/test_disable_segments_from_index_task.py | 7 ++++--- .../tasks/test_document_indexing_update_task.py | 11 ++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py index 3e9a0c8f7f..6e03bd9351 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_disable_segments_from_index_task.py @@ -9,6 +9,7 @@ The task is responsible for removing document segments from the search index whe from unittest.mock import MagicMock, patch from faker import Faker +from sqlalchemy import select from sqlalchemy.orm import Session from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType @@ -471,9 +472,9 @@ class TestDisableSegmentsFromIndexTask: db_session_with_containers.refresh(segments[1]) # Check that segments are re-enabled after error - updated_segments = ( - db_session_with_containers.query(DocumentSegment).where(DocumentSegment.id.in_(segment_ids)).all() - ) + updated_segments = db_session_with_containers.scalars( + select(DocumentSegment).where(DocumentSegment.id.in_(segment_ids)) + ).all() for segment in updated_segments: assert segment.enabled is True diff --git a/api/tests/test_containers_integration_tests/tasks/test_document_indexing_update_task.py b/api/tests/test_containers_integration_tests/tasks/test_document_indexing_update_task.py index d94abf2b40..a9a8c0f30c 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_document_indexing_update_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_document_indexing_update_task.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch import pytest from faker import Faker +from sqlalchemy import func, select from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from models import Account, Tenant, TenantAccountJoin, TenantAccountRole @@ -123,13 +124,13 @@ class TestDocumentIndexingUpdateTask: db_session_with_containers.expire_all() # Assert document status updated before reindex - updated = db_session_with_containers.query(Document).where(Document.id == document.id).first() + updated = db_session_with_containers.scalar(select(Document).where(Document.id == document.id).limit(1)) assert updated.indexing_status == IndexingStatus.PARSING assert updated.processing_started_at is not None # Segments should be deleted - remaining = ( - db_session_with_containers.query(DocumentSegment).where(DocumentSegment.document_id == document.id).count() + remaining = db_session_with_containers.scalar( + select(func.count()).select_from(DocumentSegment).where(DocumentSegment.document_id == document.id) ) assert remaining == 0 @@ -167,8 +168,8 @@ class TestDocumentIndexingUpdateTask: mock_external_dependencies["runner_instance"].run.assert_called_once() # Segments should remain (since clean failed before DB delete) - remaining = ( - db_session_with_containers.query(DocumentSegment).where(DocumentSegment.document_id == document.id).count() + remaining = db_session_with_containers.scalar( + select(func.count()).select_from(DocumentSegment).where(DocumentSegment.document_id == document.id) ) assert remaining > 0 From 68adcfc474c7be16c150edb6c1abdc6cfea40735 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Tue, 14 Apr 2026 15:58:24 +0800 Subject: [PATCH 042/103] add migration --- .../vdb/tidb_on_qdrant/tidb_service.py | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py diff --git a/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py b/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py new file mode 100644 index 0000000000..68ac02de25 --- /dev/null +++ b/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py @@ -0,0 +1,298 @@ +import logging +import time +import uuid +from collections.abc import Sequence + +import httpx +from httpx import DigestAuth + +from configs import dify_config +from core.helper.http_client_pooling import get_pooled_http_client +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from models.dataset import TidbAuthBinding +from models.enums import TidbAuthBindingStatus + +logger = logging.getLogger(__name__) + +# Reuse a pooled HTTP client for all TiDB Cloud requests to minimize connection churn +_tidb_http_client: httpx.Client = get_pooled_http_client( + "tidb:cloud", + lambda: httpx.Client(limits=httpx.Limits(max_keepalive_connections=50, max_connections=100)), +) + + +class TidbService: + @staticmethod + def fetch_qdrant_endpoint( + api_url: str, public_key: str, private_key: str, cluster_id: str + ) -> str | None: + """Fetch the qdrant endpoint for a cluster by calling the Get Cluster API. + + The Get Cluster response contains ``status.connection_strings.standard.host`` + (e.g. ``gateway01.xx.tidbcloud.com``). We prepend ``qdrant-`` and wrap it + as an ``https://`` URL. + """ + try: + cluster_response = TidbService.get_tidb_serverless_cluster( + api_url, public_key, private_key, cluster_id + ) + if not cluster_response: + return None + # v1beta: status.connection_strings.standard.host + status = cluster_response.get("status") or {} + connection_strings = status.get("connection_strings") or {} + standard = connection_strings.get("standard") or {} + host = standard.get("host") + if host: + return f"https://qdrant-{host}" + except Exception: + logger.exception("Failed to fetch qdrant endpoint for cluster %s", cluster_id) + return None + + @staticmethod + def create_tidb_serverless_cluster( + project_id: str, api_url: str, iam_url: str, public_key: str, private_key: str, region: str + ): + """ + Creates a new TiDB Serverless cluster. + :param project_id: The project ID of the TiDB Cloud project (required). + :param api_url: The URL of the TiDB Cloud API (required). + :param iam_url: The URL of the TiDB Cloud IAM API (required). + :param public_key: The public key for the API (required). + :param private_key: The private key for the API (required). + :param region: The region where the cluster will be created (required). + + :return: The response from the API. + """ + + region_object = { + "name": region, + } + + labels = { + "tidb.cloud/project": project_id, + } + + spending_limit = { + "monthly": dify_config.TIDB_SPEND_LIMIT, + } + password = str(uuid.uuid4()).replace("-", "")[:16] + display_name = str(uuid.uuid4()).replace("-", "")[:16] + cluster_data = { + "displayName": display_name, + "region": region_object, + "labels": labels, + "spendingLimit": spending_limit, + "rootPassword": password, + } + + response = _tidb_http_client.post( + f"{api_url}/clusters", json=cluster_data, auth=DigestAuth(public_key, private_key) + ) + + if response.status_code == 200: + response_data = response.json() + cluster_id = response_data["clusterId"] + retry_count = 0 + max_retries = 30 + while retry_count < max_retries: + cluster_response = TidbService.get_tidb_serverless_cluster(api_url, public_key, private_key, cluster_id) + if cluster_response["state"] == "ACTIVE": + user_prefix = cluster_response["userPrefix"] + qdrant_endpoint = TidbService.fetch_qdrant_endpoint( + api_url, public_key, private_key, cluster_id + ) + return { + "cluster_id": cluster_id, + "cluster_name": display_name, + "account": f"{user_prefix}.root", + "password": password, + "qdrant_endpoint": qdrant_endpoint, + } + time.sleep(30) # wait 30 seconds before retrying + retry_count += 1 + else: + response.raise_for_status() + + @staticmethod + def delete_tidb_serverless_cluster(api_url: str, public_key: str, private_key: str, cluster_id: str): + """ + Deletes a specific TiDB Serverless cluster. + + :param api_url: The URL of the TiDB Cloud API (required). + :param public_key: The public key for the API (required). + :param private_key: The private key for the API (required). + :param cluster_id: The ID of the cluster to be deleted (required). + :return: The response from the API. + """ + + response = _tidb_http_client.delete( + f"{api_url}/clusters/{cluster_id}", auth=DigestAuth(public_key, private_key) + ) + + if response.status_code == 200: + return response.json() + else: + response.raise_for_status() + + @staticmethod + def get_tidb_serverless_cluster(api_url: str, public_key: str, private_key: str, cluster_id: str): + """ + Deletes a specific TiDB Serverless cluster. + + :param api_url: The URL of the TiDB Cloud API (required). + :param public_key: The public key for the API (required). + :param private_key: The private key for the API (required). + :param cluster_id: The ID of the cluster to be deleted (required). + :return: The response from the API. + """ + + response = _tidb_http_client.get(f"{api_url}/clusters/{cluster_id}", auth=DigestAuth(public_key, private_key)) + + if response.status_code == 200: + return response.json() + else: + response.raise_for_status() + + @staticmethod + def change_tidb_serverless_root_password( + api_url: str, public_key: str, private_key: str, cluster_id: str, account: str, new_password: str + ): + """ + Changes the root password of a specific TiDB Serverless cluster. + + :param api_url: The URL of the TiDB Cloud API (required). + :param public_key: The public key for the API (required). + :param private_key: The private key for the API (required). + :param cluster_id: The ID of the cluster for which the password is to be changed (required).+ + :param account: The account for which the password is to be changed (required). + :param new_password: The new password for the root user (required). + :return: The response from the API. + """ + + body = {"password": new_password, "builtinRole": "role_admin", "customRoles": []} + + response = _tidb_http_client.patch( + f"{api_url}/clusters/{cluster_id}/sqlUsers/{account}", + json=body, + auth=DigestAuth(public_key, private_key), + ) + + if response.status_code == 200: + return response.json() + else: + response.raise_for_status() + + @staticmethod + def batch_update_tidb_serverless_cluster_status( + tidb_serverless_list: Sequence[TidbAuthBinding], + project_id: str, + api_url: str, + iam_url: str, + public_key: str, + private_key: str, + ): + """ + Update the status of a new TiDB Serverless cluster. + :param tidb_serverless_list: The TiDB serverless list (required). + :param project_id: The project ID of the TiDB Cloud project (required). + :param api_url: The URL of the TiDB Cloud API (required). + :param iam_url: The URL of the TiDB Cloud IAM API (required). + :param public_key: The public key for the API (required). + :param private_key: The private key for the API (required). + + :return: The response from the API. + """ + tidb_serverless_list_map = {item.cluster_id: item for item in tidb_serverless_list} + cluster_ids = [item.cluster_id for item in tidb_serverless_list] + params = {"clusterIds": cluster_ids, "view": "BASIC"} + response = _tidb_http_client.get( + f"{api_url}/clusters:batchGet", params=params, auth=DigestAuth(public_key, private_key) + ) + + if response.status_code == 200: + response_data = response.json() + for item in response_data["clusters"]: + state = item["state"] + userPrefix = item["userPrefix"] + if state == "ACTIVE" and len(userPrefix) > 0: + cluster_info = tidb_serverless_list_map[item["clusterId"]] + cluster_info.status = TidbAuthBindingStatus.ACTIVE + cluster_info.account = f"{userPrefix}.root" + db.session.add(cluster_info) + db.session.commit() + else: + response.raise_for_status() + + @staticmethod + def batch_create_tidb_serverless_cluster( + batch_size: int, project_id: str, api_url: str, iam_url: str, public_key: str, private_key: str, region: str + ) -> list[dict]: + """ + Creates a new TiDB Serverless cluster. + :param batch_size: The batch size (required). + :param project_id: The project ID of the TiDB Cloud project (required). + :param api_url: The URL of the TiDB Cloud API (required). + :param iam_url: The URL of the TiDB Cloud IAM API (required). + :param public_key: The public key for the API (required). + :param private_key: The private key for the API (required). + :param region: The region where the cluster will be created (required). + + :return: The response from the API. + """ + clusters = [] + for _ in range(batch_size): + region_object = { + "name": region, + } + + labels = { + "tidb.cloud/project": project_id, + } + + spending_limit = { + "monthly": dify_config.TIDB_SPEND_LIMIT, + } + password = str(uuid.uuid4()).replace("-", "")[:16] + display_name = str(uuid.uuid4()).replace("-", "") + cluster_data = { + "cluster": { + "displayName": display_name, + "region": region_object, + "labels": labels, + "spendingLimit": spending_limit, + "rootPassword": password, + } + } + cache_key = f"tidb_serverless_cluster_password:{display_name}" + redis_client.setex(cache_key, 3600, password) + clusters.append(cluster_data) + + request_body = {"requests": clusters} + response = _tidb_http_client.post( + f"{api_url}/clusters:batchCreate", json=request_body, auth=DigestAuth(public_key, private_key) + ) + + if response.status_code == 200: + response_data = response.json() + cluster_infos = [] + for item in response_data["clusters"]: + cache_key = f"tidb_serverless_cluster_password:{item['displayName']}" + cached_password = redis_client.get(cache_key) + if not cached_password: + continue + cluster_info = { + "cluster_id": item["clusterId"], + "cluster_name": item["displayName"], + "account": "root", + "password": cached_password.decode("utf-8"), + "qdrant_endpoint": TidbService.fetch_qdrant_endpoint( + api_url, public_key, private_key, item["clusterId"] + ), + } + cluster_infos.append(cluster_info) + return cluster_infos + else: + response.raise_for_status() + return [] From d7ad2baf7958992199aa69648a46cc00cb863784 Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 14 Apr 2026 16:15:07 +0800 Subject: [PATCH 043/103] chore: clarify tracing error copy to direct users to the Tracing tab (#35153) --- pnpm-lock.yaml | 12 ---- .../base/ui/select/__tests__/index.spec.tsx | 13 ++-- .../panel/__tests__/workflow-preview.spec.tsx | 40 +++++++++++- .../workflow/panel/workflow-preview.tsx | 5 ++ .../workflow/run/__tests__/status.spec.tsx | 65 ++++++++++++++++++- web/app/components/workflow/run/index.tsx | 1 + .../components/workflow/run/result-panel.tsx | 3 + web/app/components/workflow/run/status.tsx | 32 ++++++++- web/i18n/ar-TN/workflow.json | 2 +- web/i18n/de-DE/workflow.json | 2 +- web/i18n/en-US/workflow.json | 2 +- web/i18n/es-ES/workflow.json | 2 +- web/i18n/fa-IR/workflow.json | 2 +- web/i18n/fr-FR/workflow.json | 2 +- web/i18n/hi-IN/workflow.json | 2 +- web/i18n/id-ID/workflow.json | 2 +- web/i18n/it-IT/workflow.json | 2 +- web/i18n/ja-JP/workflow.json | 2 +- web/i18n/ko-KR/workflow.json | 2 +- web/i18n/nl-NL/workflow.json | 2 +- web/i18n/pl-PL/workflow.json | 2 +- web/i18n/pt-BR/workflow.json | 2 +- web/i18n/ro-RO/workflow.json | 2 +- web/i18n/ru-RU/workflow.json | 2 +- web/i18n/sl-SI/workflow.json | 2 +- web/i18n/th-TH/workflow.json | 2 +- web/i18n/tr-TR/workflow.json | 2 +- web/i18n/uk-UA/workflow.json | 2 +- web/i18n/vi-VN/workflow.json | 2 +- web/i18n/zh-Hans/workflow.json | 2 +- web/i18n/zh-Hant/workflow.json | 2 +- web/package.json | 2 - 32 files changed, 167 insertions(+), 52 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8901c7948f..4444981601 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,9 +24,6 @@ catalogs: '@cucumber/cucumber': specifier: 12.8.0 version: 12.8.0 - '@date-fns/tz': - specifier: 1.4.1 - version: 1.4.1 '@egoist/tailwindcss-icons': specifier: 1.9.2 version: 1.9.2 @@ -270,9 +267,6 @@ catalogs: cron-parser: specifier: 5.5.0 version: 5.5.0 - date-fns: - specifier: 4.1.0 - version: 4.1.0 dayjs: specifier: 1.11.20 version: 1.11.20 @@ -655,9 +649,6 @@ importers: '@base-ui/react': specifier: 'catalog:' version: 1.4.0(@date-fns/tz@1.4.1)(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@date-fns/tz': - specifier: 'catalog:' - version: 1.4.1 '@emoji-mart/data': specifier: 'catalog:' version: 1.2.1 @@ -760,9 +751,6 @@ importers: cron-parser: specifier: 'catalog:' version: 5.5.0 - date-fns: - specifier: 'catalog:' - version: 4.1.0 dayjs: specifier: 'catalog:' version: 1.11.20 diff --git a/web/app/components/base/ui/select/__tests__/index.spec.tsx b/web/app/components/base/ui/select/__tests__/index.spec.tsx index f33b1eb650..124eb4d60e 100644 --- a/web/app/components/base/ui/select/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/select/__tests__/index.spec.tsx @@ -42,12 +42,10 @@ const renderOpenSelect = ({ describe('Select wrappers', () => { describe('Select root integration', () => { - it('should associate the hidden input with an external form and preserve autocomplete hints', () => { - const formId = 'profile-form' + it('should submit the hidden input value and preserve autocomplete hints inside a form', () => { const { container } = render( - <> -
- @@ -56,13 +54,12 @@ describe('Select wrappers', () => { New York - , +
, ) const hiddenInput = container.querySelector('input[name="city"]') - const form = container.querySelector(`#${formId}`) as HTMLFormElement + const form = screen.getByRole('form', { name: 'profile form' }) as HTMLFormElement - expect(hiddenInput).toHaveAttribute('form', formId) expect(hiddenInput).toHaveAttribute('autocomplete', 'address-level2') expect(new FormData(form).get('city')).toBe('seattle') }) diff --git a/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx b/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx index 860322d729..e3c85bd2ad 100644 --- a/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx +++ b/web/app/components/workflow/panel/__tests__/workflow-preview.spec.tsx @@ -33,7 +33,18 @@ vi.mock('@/app/components/workflow/hooks', () => ({ })) vi.mock('@/app/components/workflow/run/result-panel', () => ({ - default: ({ status }: { status?: string }) =>
{status}
, + default: ({ + status, + onOpenTracingTab, + }: { + status?: string + onOpenTracingTab?: () => void + }) => ( +
+
{status}
+ +
+ ), })) vi.mock('@/app/components/workflow/run/result-text', () => ({ @@ -329,6 +340,33 @@ describe('WorkflowPreview', () => { expect(screen.getByTestId('result-panel')).toBeInTheDocument() }) + it('should switch to the tracing tab when result panel requests it', async () => { + const user = userEvent.setup() + + renderWorkflowComponent( + , + { + initialStoreState: { + workflowRunningData: { + ...createWorkflowRunningData({ + result: createWorkflowResult({ + status: 'partial-succeeded', + files: [], + }), + tracing: [createNodeTracing()], + }), + resultText: 'ready', + } as NonNullable, + }, + }, + ) + + await user.click(screen.getByText('runLog.detail')) + await user.click(screen.getByRole('button', { name: 'open-tracing' })) + + expect(screen.getByTestId('tracing-panel')).toHaveTextContent('1') + }) + it('should resize the preview panel within the allowed workflow canvas bounds', async () => { const { container, store } = renderWorkflowComponent( , diff --git a/web/app/components/workflow/panel/workflow-preview.tsx b/web/app/components/workflow/panel/workflow-preview.tsx index d3950dcbca..e1e442c0cb 100644 --- a/web/app/components/workflow/panel/workflow-preview.tsx +++ b/web/app/components/workflow/panel/workflow-preview.tsx @@ -101,6 +101,10 @@ const WorkflowPreview = () => { await submitHumanInputForm(formToken, formData) }, []) + const handleOpenTracingTab = useCallback(() => { + switchTab('TRACING') + }, []) + return (
{ created_by={(workflowRunningData?.result?.created_by as any)?.name} steps={workflowRunningData?.result?.total_steps} exceptionCounts={workflowRunningData?.result?.exceptions_count} + onOpenTracingTab={handleOpenTracingTab} /> )} {currentTab === 'DETAIL' && !workflowRunningData?.result && ( diff --git a/web/app/components/workflow/run/__tests__/status.spec.tsx b/web/app/components/workflow/run/__tests__/status.spec.tsx index 01f32c4c47..24682aa47f 100644 --- a/web/app/components/workflow/run/__tests__/status.spec.tsx +++ b/web/app/components/workflow/run/__tests__/status.spec.tsx @@ -1,11 +1,60 @@ import type { WorkflowPausedDetailsResponse } from '@/models/log' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' +import { cloneElement, isValidElement } from 'react' import { createDocLinkMock, resolveDocLink } from '../../__tests__/i18n' import Status from '../status' const mockDocLink = createDocLinkMock() const mockUseWorkflowPausedDetails = vi.fn() +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => { + const fullKey = options?.ns ? `${options.ns}.${key}` : key + if (fullKey === 'workflow.nodes.common.errorHandle.partialSucceeded.tip') + return 'There are {{num}} nodes in the process running abnormally, please go to TRACING to check the logs.' + + const params = { ...options } + delete params.ns + const suffix = Object.keys(params).length > 0 ? `:${JSON.stringify(params)}` : '' + return `${fullKey}${suffix}` + }, + }), + Trans: ({ + i18nKey, + values, + components, + }: { + i18nKey: string + values?: { + num?: string | number + } + components?: Record + }) => { + if (i18nKey !== 'nodes.common.errorHandle.partialSucceeded.tip') + return {i18nKey} + + const tracingLink = components?.tracingLink + const tracingNode = isValidElement(tracingLink) + ? cloneElement(tracingLink, undefined, 'TRACING') + : 'TRACING' + + return ( + + There are + {' '} + {values?.num} + {' '} + nodes in the process running abnormally, please go to + {' '} + {tracingNode} + {' '} + to check the logs. + + ) + }, +})) + vi.mock('@/context/i18n', () => ({ useDocLink: () => mockDocLink, })) @@ -64,14 +113,24 @@ describe('Status', () => { expect(screen.getByText('FAIL')).toBeInTheDocument() expect(screen.getByText('Something broke')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.common.errorHandle.partialSucceeded.tip:{"num":2}')).toBeInTheDocument() + expect(screen.getAllByText((_, element) => element?.textContent === 'There are 2 nodes in the process running abnormally, please go to TRACING to check the logs.')).toHaveLength(2) }) it('renders the partial-succeeded warning summary', () => { render() expect(screen.getByText('PARTIAL SUCCESS')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.common.errorHandle.partialSucceeded.tip:{"num":3}')).toBeInTheDocument() + expect(screen.getAllByText((_, element) => element?.textContent === 'There are 3 nodes in the process running abnormally, please go to TRACING to check the logs.')).toHaveLength(2) + }) + + it('opens the tracing tab when clicking the TRACING link', () => { + const onOpenTracingTab = vi.fn() + + render() + + fireEvent.click(screen.getByRole('link', { name: 'TRACING' })) + + expect(onOpenTracingTab).toHaveBeenCalledTimes(1) }) it('renders the exception learn-more link', () => { diff --git a/web/app/components/workflow/run/index.tsx b/web/app/components/workflow/run/index.tsx index d39e6d43c3..417d38657e 100644 --- a/web/app/components/workflow/run/index.tsx +++ b/web/app/components/workflow/run/index.tsx @@ -174,6 +174,7 @@ const RunPanel: FC = ({ exceptionCounts={runDetail.exceptions_count} isListening={isListening} workflowRunId={runDetail.id} + onOpenTracingTab={() => switchTab('TRACING')} /> )} {!loading && currentTab === 'DETAIL' && !runDetail && isListening && ( diff --git a/web/app/components/workflow/run/result-panel.tsx b/web/app/components/workflow/run/result-panel.tsx index 58f783e6c4..c7f4a45540 100644 --- a/web/app/components/workflow/run/result-panel.tsx +++ b/web/app/components/workflow/run/result-panel.tsx @@ -42,6 +42,7 @@ export type ResultPanelProps = { execution_metadata?: any isListening?: boolean workflowRunId?: string + onOpenTracingTab?: () => void handleShowIterationResultList?: (detail: NodeTracing[][], iterDurationMap: any) => void handleShowLoopResultList?: (detail: NodeTracing[][], loopDurationMap: any) => void onShowRetryDetail?: (detail: NodeTracing[]) => void @@ -69,6 +70,7 @@ const ResultPanel: FC = ({ execution_metadata, isListening = false, workflowRunId, + onOpenTracingTab, handleShowIterationResultList, handleShowLoopResultList, onShowRetryDetail, @@ -92,6 +94,7 @@ const ResultPanel: FC = ({ exceptionCounts={exceptionCounts} isListening={isListening} workflowRunId={workflowRunId} + onOpenTracingTab={onOpenTracingTab} />
diff --git a/web/app/components/workflow/run/status.tsx b/web/app/components/workflow/run/status.tsx index d4ecfcc0fd..233c6207a9 100644 --- a/web/app/components/workflow/run/status.tsx +++ b/web/app/components/workflow/run/status.tsx @@ -1,7 +1,7 @@ 'use client' import type { FC } from 'react' import { useMemo } from 'react' -import { useTranslation } from 'react-i18next' +import { Trans, useTranslation } from 'react-i18next' import Indicator from '@/app/components/header/indicator' import StatusContainer from '@/app/components/workflow/run/status-container' import { useDocLink } from '@/context/i18n' @@ -16,6 +16,7 @@ type ResultProps = { exceptionCounts?: number isListening?: boolean workflowRunId?: string + onOpenTracingTab?: () => void } const StatusPanel: FC = ({ @@ -26,6 +27,7 @@ const StatusPanel: FC = ({ exceptionCounts, isListening = false, workflowRunId, + onOpenTracingTab, }) => { const { t } = useTranslation() const docLink = useDocLink() @@ -65,6 +67,30 @@ const StatusPanel: FC = ({ return inputURLs }, [pausedDetails]) + const partialSucceededTip = exceptionCounts + ? ( + { + e.preventDefault() + onOpenTracingTab() + }} + /> + ) + : , + }} + /> + ) + : null + return (
@@ -160,7 +186,7 @@ const StatusPanel: FC = ({ <>
- {t('nodes.common.errorHandle.partialSucceeded.tip', { ns: 'workflow', num: exceptionCounts })} + {partialSucceededTip}
) @@ -172,7 +198,7 @@ const StatusPanel: FC = ({ <>
- {t('nodes.common.errorHandle.partialSucceeded.tip', { ns: 'workflow', num: exceptionCounts })} + {partialSucceededTip}
) diff --git a/web/i18n/ar-TN/workflow.json b/web/i18n/ar-TN/workflow.json index e0e498e8d4..56192350c3 100644 --- a/web/i18n/ar-TN/workflow.json +++ b/web/i18n/ar-TN/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "فرع الفشل", "nodes.common.errorHandle.none.desc": "ستتوقف العقدة عن العمل في حالة حدوث استثناء ولم يتم التعامل معه", "nodes.common.errorHandle.none.title": "لا شيء", - "nodes.common.errorHandle.partialSucceeded.tip": "هناك {{num}} عقد في العملية تعمل بشكل غير طبيعي، يرجى الانتقال إلى التتبع للتحقق من السجلات.", + "nodes.common.errorHandle.partialSucceeded.tip": "هناك {{num}} عقد في العملية تعمل بشكل غير طبيعي، يرجى الانتقال إلى التتبع للتحقق من السجلات.", "nodes.common.errorHandle.tip": "استراتيجية التعامل مع الاستثناءات، يتم تشغيلها عندما تواجه العقدة استثناءً.", "nodes.common.errorHandle.title": "معالجة الأخطاء", "nodes.common.inputVars": "متغيرات الإدخال", diff --git a/web/i18n/de-DE/workflow.json b/web/i18n/de-DE/workflow.json index 7a1ddeeb1c..75cb0d30ca 100644 --- a/web/i18n/de-DE/workflow.json +++ b/web/i18n/de-DE/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Fehlgeschlagener Zweig", "nodes.common.errorHandle.none.desc": "Der Knoten wird nicht mehr ausgeführt, wenn eine Ausnahme auftritt und nicht behandelt wird", "nodes.common.errorHandle.none.title": "Nichts", - "nodes.common.errorHandle.partialSucceeded.tip": "Es gibt {{num}} Knoten im Prozess, die nicht normal laufen, bitte gehen Sie zur Ablaufverfolgung, um die Protokolle zu überprüfen.", + "nodes.common.errorHandle.partialSucceeded.tip": "Es gibt {{num}} Knoten im Prozess, die nicht normal laufen, bitte gehen Sie zur Ablaufverfolgung, um die Protokolle zu überprüfen.", "nodes.common.errorHandle.tip": "Ausnahmebehandlungsstrategie, die ausgelöst wird, wenn ein Knoten auf eine Ausnahme stößt.", "nodes.common.errorHandle.title": "Fehlerbehandlung", "nodes.common.inputVars": "Eingabevariablen", diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 6f6e5de2cc..42522950b8 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Fail Branch", "nodes.common.errorHandle.none.desc": "The node will stop running if an exception occurs and is not handled", "nodes.common.errorHandle.none.title": "None", - "nodes.common.errorHandle.partialSucceeded.tip": "There are {{num}} nodes in the process running abnormally, please go to tracing to check the logs.", + "nodes.common.errorHandle.partialSucceeded.tip": "There are {{num}} nodes in the process running abnormally, please go to TRACING to check the logs.", "nodes.common.errorHandle.tip": "Exception handling strategy, triggered when a node encounters an exception.", "nodes.common.errorHandle.title": "Error Handling", "nodes.common.inputVars": "Input Variables", diff --git a/web/i18n/es-ES/workflow.json b/web/i18n/es-ES/workflow.json index 1955357c45..5545707b4f 100644 --- a/web/i18n/es-ES/workflow.json +++ b/web/i18n/es-ES/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Rama de error", "nodes.common.errorHandle.none.desc": "El nodo dejará de ejecutarse si se produce una excepción y no se controla", "nodes.common.errorHandle.none.title": "Ninguno", - "nodes.common.errorHandle.partialSucceeded.tip": "Hay nodos {{num}} en el proceso que se ejecutan de manera anormal, vaya a rastreo para verificar los registros.", + "nodes.common.errorHandle.partialSucceeded.tip": "Hay nodos {{num}} en el proceso que se ejecutan de manera anormal, vaya a rastreo para verificar los registros.", "nodes.common.errorHandle.tip": "Estrategia de control de excepciones, que se desencadena cuando un nodo encuentra una excepción.", "nodes.common.errorHandle.title": "Manejo de errores", "nodes.common.inputVars": "Variables de entrada", diff --git a/web/i18n/fa-IR/workflow.json b/web/i18n/fa-IR/workflow.json index e957d45267..fbe6685681 100644 --- a/web/i18n/fa-IR/workflow.json +++ b/web/i18n/fa-IR/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "شاخه شکست", "nodes.common.errorHandle.none.desc": "اگر استثنایی رخ دهد و مدیریت نشود، گره از کار می‌افتد", "nodes.common.errorHandle.none.title": "هیچ‌کدام", - "nodes.common.errorHandle.partialSucceeded.tip": "{{num}} گره با خطا مواجه شدند؛ برای بررسی لاگ‌ها به ردیابی مراجعه کنید.", + "nodes.common.errorHandle.partialSucceeded.tip": "{{num}} گره با خطا مواجه شدند؛ برای بررسی لاگ‌ها به ردیابی مراجعه کنید.", "nodes.common.errorHandle.tip": "استراتژی مدیریت استثنا؛ زمانی که گره با خطا مواجه شود فعال می‌شود.", "nodes.common.errorHandle.title": "مدیریت خطا", "nodes.common.inputVars": "متغیرهای ورودی", diff --git a/web/i18n/fr-FR/workflow.json b/web/i18n/fr-FR/workflow.json index ec23ac5c98..b7f7048ad2 100644 --- a/web/i18n/fr-FR/workflow.json +++ b/web/i18n/fr-FR/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Branche d’échec", "nodes.common.errorHandle.none.desc": "Le nœud cessera de s’exécuter si une exception se produit et n’est pas gérée", "nodes.common.errorHandle.none.title": "Aucun", - "nodes.common.errorHandle.partialSucceeded.tip": "Il y a des nœuds {{num}} dans le processus qui fonctionnent anormalement, veuillez aller dans le traçage pour vérifier les journaux.", + "nodes.common.errorHandle.partialSucceeded.tip": "Il y a des nœuds {{num}} dans le processus qui fonctionnent anormalement, veuillez aller dans le traçage pour vérifier les journaux.", "nodes.common.errorHandle.tip": "Stratégie de gestion des exceptions, déclenchée lorsqu’un nœud rencontre une exception.", "nodes.common.errorHandle.title": "Gestion des erreurs", "nodes.common.inputVars": "Variables d’entrée", diff --git a/web/i18n/hi-IN/workflow.json b/web/i18n/hi-IN/workflow.json index f18de6aa3c..9166eb43d8 100644 --- a/web/i18n/hi-IN/workflow.json +++ b/web/i18n/hi-IN/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "असफल शाखा", "nodes.common.errorHandle.none.desc": "यदि कोई अपवाद होता है और हैंडल नहीं किया जाता है, तो नोड चलना बंद कर देगा", "nodes.common.errorHandle.none.title": "कोई नहीं", - "nodes.common.errorHandle.partialSucceeded.tip": "प्रक्रिया में {{num}} नोड्स असामान्य रूप से चल रहे हैं, कृपया लॉग की जांच करने के लिए ट्रेसिंग पर जाएं।", + "nodes.common.errorHandle.partialSucceeded.tip": "प्रक्रिया में {{num}} नोड्स असामान्य रूप से चल रहे हैं, कृपया लॉग की जांच करने के लिए ट्रेसिंग पर जाएं।", "nodes.common.errorHandle.tip": "अपवाद हैंडलिंग रणनीति, ट्रिगर जब एक नोड एक अपवाद का सामना करता है।", "nodes.common.errorHandle.title": "त्रुटि हैंडलिंग", "nodes.common.inputVars": "इनपुट चर", diff --git a/web/i18n/id-ID/workflow.json b/web/i18n/id-ID/workflow.json index 5030489cb1..7e72577e3b 100644 --- a/web/i18n/id-ID/workflow.json +++ b/web/i18n/id-ID/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Cabang Gagal", "nodes.common.errorHandle.none.desc": "Node akan berhenti berjalan jika pengecualian terjadi dan tidak ditangani", "nodes.common.errorHandle.none.title": "Tidak", - "nodes.common.errorHandle.partialSucceeded.tip": "Ada {{num}} node dalam proses yang berjalan tidak normal, silakan pergi ke tracing untuk memeriksa log.", + "nodes.common.errorHandle.partialSucceeded.tip": "Ada {{num}} node dalam proses yang berjalan tidak normal, silakan pergi ke tracing untuk memeriksa log.", "nodes.common.errorHandle.tip": "Strategi penanganan pengecualian, dipicu ketika simpul menemukan pengecualian.", "nodes.common.errorHandle.title": "Penanganan Kesalahan", "nodes.common.inputVars": "Variabel Masukan", diff --git a/web/i18n/it-IT/workflow.json b/web/i18n/it-IT/workflow.json index a39e00c5d8..2fde43f694 100644 --- a/web/i18n/it-IT/workflow.json +++ b/web/i18n/it-IT/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Ramo fallito", "nodes.common.errorHandle.none.desc": "L'esecuzione del nodo verrà interrotta se si verifica un'eccezione e non viene gestita", "nodes.common.errorHandle.none.title": "Nessuno", - "nodes.common.errorHandle.partialSucceeded.tip": "Ci sono {{num}} nodi nel processo che funzionano in modo anomalo, si prega di andare su tracing per controllare i log.", + "nodes.common.errorHandle.partialSucceeded.tip": "Ci sono {{num}} nodi nel processo che funzionano in modo anomalo, si prega di andare su tracing per controllare i log.", "nodes.common.errorHandle.tip": "Strategia di gestione delle eccezioni, attivata quando un nodo rileva un'eccezione.", "nodes.common.errorHandle.title": "Gestione degli errori", "nodes.common.inputVars": "Variabili di input", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index 089468053a..11cf9caa34 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "例外分岐", "nodes.common.errorHandle.none.desc": "例外発生時に処理を停止", "nodes.common.errorHandle.none.title": "処理なし", - "nodes.common.errorHandle.partialSucceeded.tip": "{{num}}個のノードで異常発生。ログはトレース画面で確認可能", + "nodes.common.errorHandle.partialSucceeded.tip": "{{num}}個のノードで異常発生。ログはトレース画面で確認可能", "nodes.common.errorHandle.tip": "ノード例外発生時の処理ポリシーを設定", "nodes.common.errorHandle.title": "例外処理", "nodes.common.inputVars": "入力変数", diff --git a/web/i18n/ko-KR/workflow.json b/web/i18n/ko-KR/workflow.json index ab602c391c..c93f417361 100644 --- a/web/i18n/ko-KR/workflow.json +++ b/web/i18n/ko-KR/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "실패 분기", "nodes.common.errorHandle.none.desc": "예외가 발생하고 처리되지 않으면 노드 실행이 중지됩니다", "nodes.common.errorHandle.none.title": "없음", - "nodes.common.errorHandle.partialSucceeded.tip": "프로세스에 {{num}} 노드가 비정상적으로 실행 중입니다. 추적으로 이동하여 로그를 확인하십시오.", + "nodes.common.errorHandle.partialSucceeded.tip": "프로세스에 {{num}} 노드가 비정상적으로 실행 중입니다. 추적으로 이동하여 로그를 확인하십시오.", "nodes.common.errorHandle.tip": "노드에 예외가 발생할 때 트리거되는 예외 처리 전략입니다.", "nodes.common.errorHandle.title": "오류 처리", "nodes.common.inputVars": "입력 변수", diff --git a/web/i18n/nl-NL/workflow.json b/web/i18n/nl-NL/workflow.json index b706c42962..4a6ccd8937 100644 --- a/web/i18n/nl-NL/workflow.json +++ b/web/i18n/nl-NL/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Fail Branch", "nodes.common.errorHandle.none.desc": "The node will stop running if an exception occurs and is not handled", "nodes.common.errorHandle.none.title": "None", - "nodes.common.errorHandle.partialSucceeded.tip": "There are {{num}} nodes in the process running abnormally, please go to tracing to check the logs.", + "nodes.common.errorHandle.partialSucceeded.tip": "There are {{num}} nodes in the process running abnormally, please go to tracing to check the logs.", "nodes.common.errorHandle.tip": "Exception handling strategy, triggered when a node encounters an exception.", "nodes.common.errorHandle.title": "Error Handling", "nodes.common.inputVars": "Input Variables", diff --git a/web/i18n/pl-PL/workflow.json b/web/i18n/pl-PL/workflow.json index 1f54fe6437..57aa50dd4e 100644 --- a/web/i18n/pl-PL/workflow.json +++ b/web/i18n/pl-PL/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Gałąź Fail (Gałąź Niepowodzenia", "nodes.common.errorHandle.none.desc": "Węzeł przestanie działać, jeśli wystąpi wyjątek i nie zostanie obsłużony", "nodes.common.errorHandle.none.title": "Żaden", - "nodes.common.errorHandle.partialSucceeded.tip": "W procesie {{num}} węzły działają nieprawidłowo, przejdź do śledzenia, aby sprawdzić dzienniki.", + "nodes.common.errorHandle.partialSucceeded.tip": "W procesie {{num}} węzły działają nieprawidłowo, przejdź do śledzenia, aby sprawdzić dzienniki.", "nodes.common.errorHandle.tip": "Strategia obsługi wyjątków, wyzwalana, gdy węzeł napotka wyjątek.", "nodes.common.errorHandle.title": "Obsługa błędów", "nodes.common.inputVars": "Zmienne wejściowe", diff --git a/web/i18n/pt-BR/workflow.json b/web/i18n/pt-BR/workflow.json index ab1fea2990..a0635ad814 100644 --- a/web/i18n/pt-BR/workflow.json +++ b/web/i18n/pt-BR/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Ramificação com falha", "nodes.common.errorHandle.none.desc": "O nó deixará de ser executado se ocorrer uma exceção e não for tratada", "nodes.common.errorHandle.none.title": "Nenhum", - "nodes.common.errorHandle.partialSucceeded.tip": "Existem {{num}} nós no processo em execução anormal, vá para rastreamento para verificar os logs.", + "nodes.common.errorHandle.partialSucceeded.tip": "Existem {{num}} nós no processo em execução anormal, vá para rastreamento para verificar os logs.", "nodes.common.errorHandle.tip": "Estratégia de tratamento de exceções, disparada quando um nó encontra uma exceção.", "nodes.common.errorHandle.title": "Tratamento de erros", "nodes.common.inputVars": "Variáveis de entrada", diff --git a/web/i18n/ro-RO/workflow.json b/web/i18n/ro-RO/workflow.json index dbe331e316..58a0786894 100644 --- a/web/i18n/ro-RO/workflow.json +++ b/web/i18n/ro-RO/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Ramură Fail", "nodes.common.errorHandle.none.desc": "Nodul se va opri din rulare dacă apare o excepție și nu este gestionat", "nodes.common.errorHandle.none.title": "Niciunul", - "nodes.common.errorHandle.partialSucceeded.tip": "Există {{num}} noduri în proces care rulează anormal, vă rugăm să mergeți la urmărire pentru a verifica jurnalele.", + "nodes.common.errorHandle.partialSucceeded.tip": "Există {{num}} noduri în proces care rulează anormal, vă rugăm să mergeți la urmărire pentru a verifica jurnalele.", "nodes.common.errorHandle.tip": "Strategie de gestionare a excepțiilor, declanșată atunci când un nod întâlnește o excepție.", "nodes.common.errorHandle.title": "Gestionarea erorilor", "nodes.common.inputVars": "Variabile de intrare", diff --git a/web/i18n/ru-RU/workflow.json b/web/i18n/ru-RU/workflow.json index 48a253b31b..585c9fae93 100644 --- a/web/i18n/ru-RU/workflow.json +++ b/web/i18n/ru-RU/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Неудачная ветвь", "nodes.common.errorHandle.none.desc": "Узел перестанет работать, если произойдет исключение и оно не будет обработано", "nodes.common.errorHandle.none.title": "Никакой", - "nodes.common.errorHandle.partialSucceeded.tip": "В процессе есть {{num}} узлов, которые работают ненормально, пожалуйста, перейдите к трассировке, чтобы проверить логи.", + "nodes.common.errorHandle.partialSucceeded.tip": "В процессе есть {{num}} узлов, которые работают ненормально, пожалуйста, перейдите к трассировке, чтобы проверить логи.", "nodes.common.errorHandle.tip": "Стратегия обработки исключений, запускаемая при обнаружении исключения на узле.", "nodes.common.errorHandle.title": "Обработка ошибок", "nodes.common.inputVars": "Входные переменные", diff --git a/web/i18n/sl-SI/workflow.json b/web/i18n/sl-SI/workflow.json index f2b032cfaa..f29c211ed8 100644 --- a/web/i18n/sl-SI/workflow.json +++ b/web/i18n/sl-SI/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Napaka veja", "nodes.common.errorHandle.none.desc": "Vozlišče se bo prenehalo izvajati, če pride do izjeme, ki ni obravnavana.", "nodes.common.errorHandle.none.title": "Noben", - "nodes.common.errorHandle.partialSucceeded.tip": "V procesu je {{num}} vozlišč, ki delujejo nenormalno, prosim, pojdite na sledenje, da preverite dnevnike.", + "nodes.common.errorHandle.partialSucceeded.tip": "V procesu je {{num}} vozlišč, ki delujejo nenormalno, prosim, pojdite na sledenje, da preverite dnevnike.", "nodes.common.errorHandle.tip": "Strategija ravnanja z izjemo, ki se sproži, ko vozlišče naleti na izjemo.", "nodes.common.errorHandle.title": "Obvladovanje napak", "nodes.common.inputVars": "Vhodne spremenljivke", diff --git a/web/i18n/th-TH/workflow.json b/web/i18n/th-TH/workflow.json index fb49264d71..130e2c0269 100644 --- a/web/i18n/th-TH/workflow.json +++ b/web/i18n/th-TH/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "สาขาล้มเหลว", "nodes.common.errorHandle.none.desc": "โหนดจะหยุดทํางานหากเกิดข้อยกเว้นและไม่ได้รับการจัดการ", "nodes.common.errorHandle.none.title": "ไม่มีใคร", - "nodes.common.errorHandle.partialSucceeded.tip": "มีโหนด {{num}} ในกระบวนการที่ทํางานผิดปกติ โปรดไปที่การติดตามเพื่อตรวจสอบบันทึก", + "nodes.common.errorHandle.partialSucceeded.tip": "มีโหนด {{num}} ในกระบวนการที่ทํางานผิดปกติ โปรดไปที่การติดตามเพื่อตรวจสอบบันทึก", "nodes.common.errorHandle.tip": "กลยุทธ์การจัดการข้อยกเว้น ทริกเกอร์เมื่อโหนดพบข้อยกเว้น", "nodes.common.errorHandle.title": "การจัดการข้อผิดพลาด", "nodes.common.inputVars": "ตัวแปรอินพุต", diff --git a/web/i18n/tr-TR/workflow.json b/web/i18n/tr-TR/workflow.json index 6b87b4e1e8..c6893cfb84 100644 --- a/web/i18n/tr-TR/workflow.json +++ b/web/i18n/tr-TR/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Başarısız Dal", "nodes.common.errorHandle.none.desc": "Bir özel durum oluşursa ve işlenmezse düğüm çalışmayı durdurur", "nodes.common.errorHandle.none.title": "Hiç kimse", - "nodes.common.errorHandle.partialSucceeded.tip": "İşlemde anormal şekilde çalışan {{num}} düğümleri var, lütfen günlükleri kontrol etmek için izlemeye gidin.", + "nodes.common.errorHandle.partialSucceeded.tip": "İşlemde anormal şekilde çalışan {{num}} düğümleri var, lütfen günlükleri kontrol etmek için izlemeye gidin.", "nodes.common.errorHandle.tip": "Bir düğüm bir özel durumla karşılaştığında tetiklenen özel durum işleme stratejisi.", "nodes.common.errorHandle.title": "Hata İşleme", "nodes.common.inputVars": "Giriş Değişkenleri", diff --git a/web/i18n/uk-UA/workflow.json b/web/i18n/uk-UA/workflow.json index 70d5378d22..90ee940ae7 100644 --- a/web/i18n/uk-UA/workflow.json +++ b/web/i18n/uk-UA/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Гілка невдачі", "nodes.common.errorHandle.none.desc": "Вузол припинить роботу, якщо виникне виняток і не буде оброблений", "nodes.common.errorHandle.none.title": "Ніхто", - "nodes.common.errorHandle.partialSucceeded.tip": "У процесі є вузли {{num}}, які працюють ненормально, будь ласка, перейдіть до трасування, щоб перевірити логи.", + "nodes.common.errorHandle.partialSucceeded.tip": "У процесі є вузли {{num}}, які працюють ненормально, будь ласка, перейдіть до трасування, щоб перевірити логи.", "nodes.common.errorHandle.tip": "Стратегія обробки винятків, що спрацьовує, коли вузол стикається з винятком.", "nodes.common.errorHandle.title": "Обробка помилок", "nodes.common.inputVars": "Вхідні змінні", diff --git a/web/i18n/vi-VN/workflow.json b/web/i18n/vi-VN/workflow.json index 9bf9b4d61c..6ba72b8b3d 100644 --- a/web/i18n/vi-VN/workflow.json +++ b/web/i18n/vi-VN/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "Chi nhánh thất bại", "nodes.common.errorHandle.none.desc": "Nút sẽ ngừng chạy nếu xảy ra ngoại lệ và không được xử lý", "nodes.common.errorHandle.none.title": "Không ai", - "nodes.common.errorHandle.partialSucceeded.tip": "Có {{num}} node trong quá trình chạy bất thường, vui lòng truy tìm để kiểm tra nhật ký.", + "nodes.common.errorHandle.partialSucceeded.tip": "Có {{num}} node trong quá trình chạy bất thường, vui lòng vào truy tìm để kiểm tra nhật ký.", "nodes.common.errorHandle.tip": "Chiến lược xử lý ngoại lệ, được kích hoạt khi một nút gặp phải ngoại lệ.", "nodes.common.errorHandle.title": "Xử lý lỗi", "nodes.common.inputVars": "Biến đầu vào", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 6bb832f925..c02cad5145 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "异常分支", "nodes.common.errorHandle.none.desc": "当发生异常且未处理时,节点将停止运行", "nodes.common.errorHandle.none.title": "无", - "nodes.common.errorHandle.partialSucceeded.tip": "流程中有 {{num}} 个节点运行异常,请前往追踪查看日志。", + "nodes.common.errorHandle.partialSucceeded.tip": "流程中有 {{num}} 个节点运行异常,请前往追踪查看日志。", "nodes.common.errorHandle.tip": "配置异常处理策略,当节点发生异常时触发。", "nodes.common.errorHandle.title": "异常处理", "nodes.common.inputVars": "输入变量", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index 865d8b66ae..5c7c9cfc95 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -433,7 +433,7 @@ "nodes.common.errorHandle.failBranch.title": "失敗分支", "nodes.common.errorHandle.none.desc": "如果發生異常且未得到處理,節點將停止運行", "nodes.common.errorHandle.none.title": "沒有", - "nodes.common.errorHandle.partialSucceeded.tip": "進程中有 {{num}} 個節點運行異常,請前往 tracing 查看日誌。", + "nodes.common.errorHandle.partialSucceeded.tip": "進程中有 {{num}} 個節點運行異常,請前往tracing查看日誌。", "nodes.common.errorHandle.tip": "異常處理策略,當節點遇到異常時觸發。", "nodes.common.errorHandle.title": "錯誤處理", "nodes.common.inputVars": "輸入變數", diff --git a/web/package.json b/web/package.json index 3ce16d8fb0..8bc31dce31 100644 --- a/web/package.json +++ b/web/package.json @@ -56,7 +56,6 @@ "@amplitude/analytics-browser": "catalog:", "@amplitude/plugin-session-replay-browser": "catalog:", "@base-ui/react": "catalog:", - "@date-fns/tz": "catalog:", "@emoji-mart/data": "catalog:", "@floating-ui/react": "catalog:", "@formatjs/intl-localematcher": "catalog:", @@ -91,7 +90,6 @@ "cmdk": "catalog:", "copy-to-clipboard": "catalog:", "cron-parser": "catalog:", - "date-fns": "catalog:", "dayjs": "catalog:", "decimal.js": "catalog:", "dompurify": "catalog:", From 9a47bb2f80946a14838cc4906379860713c4836b Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 14 Apr 2026 16:16:19 +0800 Subject: [PATCH 044/103] fix: doc modal hidden by config modal (#35157) --- web/app/components/plugins/readme-panel/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/app/components/plugins/readme-panel/index.tsx b/web/app/components/plugins/readme-panel/index.tsx index 516ef63415..182da360c4 100644 --- a/web/app/components/plugins/readme-panel/index.tsx +++ b/web/app/components/plugins/readme-panel/index.tsx @@ -87,7 +87,7 @@ const ReadmePanel: FC = () => { const portalContent = showType === ReadmeShowType.drawer ? ( -
+
{
) : ( -
+
Date: Tue, 14 Apr 2026 16:24:59 +0800 Subject: [PATCH 045/103] fix style --- .../src/dify_vdb_tidb_on_qdrant/tidb_service.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py index f617fb081e..cfdc9cdd59 100644 --- a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py +++ b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py @@ -1,8 +1,11 @@ +import logging import time import uuid from collections.abc import Sequence import httpx + +logger = logging.getLogger(__name__) from httpx import DigestAuth from configs import dify_config @@ -44,7 +47,7 @@ class TidbService: if host: return f"https://qdrant-{host}" except Exception: - pass + logger.exception("Failed to fetch qdrant endpoint for cluster %s", cluster_id) return None @staticmethod From d13bb0d9d13b6f4bf3eae5f26ebaeca646a288db Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:27:24 +0000 Subject: [PATCH 046/103] [autofix.ci] apply automated fixes --- .../datasource/vdb/tidb_on_qdrant/tidb_service.py | 12 +++--------- .../dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py | 4 +++- .../src/dify_vdb_tidb_on_qdrant/tidb_service.py | 12 +++--------- 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py b/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py index 68ac02de25..118bd0b84e 100644 --- a/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py +++ b/api/core/rag/datasource/vdb/tidb_on_qdrant/tidb_service.py @@ -24,9 +24,7 @@ _tidb_http_client: httpx.Client = get_pooled_http_client( class TidbService: @staticmethod - def fetch_qdrant_endpoint( - api_url: str, public_key: str, private_key: str, cluster_id: str - ) -> str | None: + def fetch_qdrant_endpoint(api_url: str, public_key: str, private_key: str, cluster_id: str) -> str | None: """Fetch the qdrant endpoint for a cluster by calling the Get Cluster API. The Get Cluster response contains ``status.connection_strings.standard.host`` @@ -34,9 +32,7 @@ class TidbService: as an ``https://`` URL. """ try: - cluster_response = TidbService.get_tidb_serverless_cluster( - api_url, public_key, private_key, cluster_id - ) + cluster_response = TidbService.get_tidb_serverless_cluster(api_url, public_key, private_key, cluster_id) if not cluster_response: return None # v1beta: status.connection_strings.standard.host @@ -100,9 +96,7 @@ class TidbService: cluster_response = TidbService.get_tidb_serverless_cluster(api_url, public_key, private_key, cluster_id) if cluster_response["state"] == "ACTIVE": user_prefix = cluster_response["userPrefix"] - qdrant_endpoint = TidbService.fetch_qdrant_endpoint( - api_url, public_key, private_key, cluster_id - ) + qdrant_endpoint = TidbService.fetch_qdrant_endpoint(api_url, public_key, private_key, cluster_id) return { "cluster_id": cluster_id, "cluster_name": display_name, diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py index 98dd662b32..4a5cfc0387 100644 --- a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py +++ b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py @@ -468,7 +468,9 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory): else: TIDB_ON_QDRANT_API_KEY = f"{tidb_auth_binding.account}:{tidb_auth_binding.password}" - qdrant_url = (tidb_auth_binding.qdrant_endpoint if tidb_auth_binding else None) or dify_config.TIDB_ON_QDRANT_URL or "" + qdrant_url = ( + (tidb_auth_binding.qdrant_endpoint if tidb_auth_binding else None) or dify_config.TIDB_ON_QDRANT_URL or "" + ) if dataset.index_struct_dict: class_prefix: str = dataset.index_struct_dict["vector_store"]["class_prefix"] diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py index cfdc9cdd59..b426cd476c 100644 --- a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py +++ b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py @@ -24,9 +24,7 @@ _tidb_http_client: httpx.Client = get_pooled_http_client( class TidbService: @staticmethod - def fetch_qdrant_endpoint( - api_url: str, public_key: str, private_key: str, cluster_id: str - ) -> str | None: + def fetch_qdrant_endpoint(api_url: str, public_key: str, private_key: str, cluster_id: str) -> str | None: """Fetch the qdrant endpoint for a cluster by calling the Get Cluster API. The Get Cluster response contains ``status.connection_strings.standard.host`` @@ -34,9 +32,7 @@ class TidbService: as an ``https://`` URL. """ try: - cluster_response = TidbService.get_tidb_serverless_cluster( - api_url, public_key, private_key, cluster_id - ) + cluster_response = TidbService.get_tidb_serverless_cluster(api_url, public_key, private_key, cluster_id) if not cluster_response: return None # v1beta: status.connection_strings.standard.host @@ -100,9 +96,7 @@ class TidbService: cluster_response = TidbService.get_tidb_serverless_cluster(api_url, public_key, private_key, cluster_id) if cluster_response["state"] == "ACTIVE": user_prefix = cluster_response["userPrefix"] - qdrant_endpoint = TidbService.fetch_qdrant_endpoint( - api_url, public_key, private_key, cluster_id - ) + qdrant_endpoint = TidbService.fetch_qdrant_endpoint(api_url, public_key, private_key, cluster_id) return { "cluster_id": cluster_id, "cluster_name": display_name, From bd7a9b5fcfd6fe922ce8c381a39c39f3b63cee11 Mon Sep 17 00:00:00 2001 From: wdeveloper16 Date: Tue, 14 Apr 2026 11:18:30 +0200 Subject: [PATCH 047/103] refactor: replace bare dict with dict[str, Any] in model provider service and core modules (#35122) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: Asuka Minato --- .../agent/output_parser/cot_output_parser.py | 4 +-- api/core/schemas/resolver.py | 2 +- api/services/model_provider_service.py | 27 +++++++++++++------ 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/api/core/agent/output_parser/cot_output_parser.py b/api/core/agent/output_parser/cot_output_parser.py index 46c1f1230d..8cccd2be6d 100644 --- a/api/core/agent/output_parser/cot_output_parser.py +++ b/api/core/agent/output_parser/cot_output_parser.py @@ -1,7 +1,7 @@ import json import re from collections.abc import Generator -from typing import Union +from typing import Any, Union from graphon.model_runtime.entities.llm_entities import LLMResultChunk @@ -11,7 +11,7 @@ from core.agent.entities import AgentScratchpadUnit class CotAgentOutputParser: @classmethod def handle_react_stream_output( - cls, llm_response: Generator[LLMResultChunk, None, None], usage_dict: dict + cls, llm_response: Generator[LLMResultChunk, None, None], usage_dict: dict[str, Any] ) -> Generator[Union[str, AgentScratchpadUnit.Action], None, None]: def parse_action(action) -> Union[str, AgentScratchpadUnit.Action]: action_name = None diff --git a/api/core/schemas/resolver.py b/api/core/schemas/resolver.py index 6e26664ac2..e267c1abd9 100644 --- a/api/core/schemas/resolver.py +++ b/api/core/schemas/resolver.py @@ -254,7 +254,7 @@ def resolve_dify_schema_refs( return resolver.resolve(schema) -def _remove_metadata_fields(schema: dict) -> dict: +def _remove_metadata_fields(schema: dict[str, Any]) -> dict[str, Any]: """ Remove metadata fields from schema that shouldn't be included in resolved output diff --git a/api/services/model_provider_service.py b/api/services/model_provider_service.py index 3f37c9b176..bf208c9bc7 100644 --- a/api/services/model_provider_service.py +++ b/api/services/model_provider_service.py @@ -1,4 +1,5 @@ import logging +from typing import Any from graphon.model_runtime.entities.model_entities import ModelType, ParameterRule @@ -168,7 +169,9 @@ class ModelProviderService: model_name=model, ) - def get_provider_credential(self, tenant_id: str, provider: str, credential_id: str | None = None) -> dict | None: + def get_provider_credential( + self, tenant_id: str, provider: str, credential_id: str | None = None + ) -> dict[str, Any] | None: """ get provider credentials. @@ -180,7 +183,7 @@ class ModelProviderService: provider_configuration = self._get_provider_configuration(tenant_id, provider) return provider_configuration.get_provider_credential(credential_id=credential_id) - def validate_provider_credentials(self, tenant_id: str, provider: str, credentials: dict): + def validate_provider_credentials(self, tenant_id: str, provider: str, credentials: dict[str, Any]): """ validate provider credentials before saving. @@ -192,7 +195,7 @@ class ModelProviderService: provider_configuration.validate_provider_credentials(credentials) def create_provider_credential( - self, tenant_id: str, provider: str, credentials: dict, credential_name: str | None + self, tenant_id: str, provider: str, credentials: dict[str, Any], credential_name: str | None ) -> None: """ Create and save new provider credentials. @@ -210,7 +213,7 @@ class ModelProviderService: self, tenant_id: str, provider: str, - credentials: dict, + credentials: dict[str, Any], credential_id: str, credential_name: str | None, ) -> None: @@ -254,7 +257,7 @@ class ModelProviderService: def get_model_credential( self, tenant_id: str, provider: str, model_type: str, model: str, credential_id: str | None - ) -> dict | None: + ) -> dict[str, Any] | None: """ Retrieve model-specific credentials. @@ -270,7 +273,9 @@ class ModelProviderService: model_type=ModelType.value_of(model_type), model=model, credential_id=credential_id ) - def validate_model_credentials(self, tenant_id: str, provider: str, model_type: str, model: str, credentials: dict): + def validate_model_credentials( + self, tenant_id: str, provider: str, model_type: str, model: str, credentials: dict[str, Any] + ): """ validate model credentials. @@ -287,7 +292,13 @@ class ModelProviderService: ) def create_model_credential( - self, tenant_id: str, provider: str, model_type: str, model: str, credentials: dict, credential_name: str | None + self, + tenant_id: str, + provider: str, + model_type: str, + model: str, + credentials: dict[str, Any], + credential_name: str | None, ) -> None: """ create and save model credentials. @@ -314,7 +325,7 @@ class ModelProviderService: provider: str, model_type: str, model: str, - credentials: dict, + credentials: dict[str, Any], credential_id: str, credential_name: str | None, ) -> None: From 729677ca2d171ee7fcdd2ee72b77368a6d919634 Mon Sep 17 00:00:00 2001 From: Yansong Zhang <916125788@qq.com> Date: Tue, 14 Apr 2026 17:24:09 +0800 Subject: [PATCH 048/103] fix style --- .../dify_vdb_tidb_on_qdrant/tidb_service.py | 4 +- .../unit_tests/test_tidb_on_qdrant_vector.py | 25 +++- .../tests/unit_tests/test_tidb_service.py | 120 ++++++++++++++++++ 3 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py index cfdc9cdd59..68ac02de25 100644 --- a/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py +++ b/api/providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py @@ -4,8 +4,6 @@ import uuid from collections.abc import Sequence import httpx - -logger = logging.getLogger(__name__) from httpx import DigestAuth from configs import dify_config @@ -15,6 +13,8 @@ from extensions.ext_redis import redis_client from models.dataset import TidbAuthBinding from models.enums import TidbAuthBindingStatus +logger = logging.getLogger(__name__) + # Reuse a pooled HTTP client for all TiDB Cloud requests to minimize connection churn _tidb_http_client: httpx.Client = get_pooled_http_client( "tidb:cloud", diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_on_qdrant_vector.py b/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_on_qdrant_vector.py index 3e9229fea5..e4fca9f931 100644 --- a/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_on_qdrant_vector.py +++ b/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_on_qdrant_vector.py @@ -114,14 +114,12 @@ class TestTidbOnQdrantVectorDeleteByIds: assert exc_info.value.status_code == 500 - def test_delete_by_ids_with_large_batch(self, vector_instance): - """Test deletion with a large batch of IDs.""" - # Create 1000 IDs + def test_delete_by_ids_with_exactly_1000(self, vector_instance): + """Test deletion with exactly 1000 IDs triggers a single batch.""" ids = [f"doc_{i}" for i in range(1000)] vector_instance.delete_by_ids(ids) - # Verify single delete call with all IDs vector_instance._client.delete.assert_called_once() call_args = vector_instance._client.delete.call_args @@ -129,11 +127,28 @@ class TestTidbOnQdrantVectorDeleteByIds: filter_obj = filter_selector.filter field_condition = filter_obj.must[0] - # Verify all 1000 IDs are in the batch assert len(field_condition.match.any) == 1000 assert "doc_0" in field_condition.match.any assert "doc_999" in field_condition.match.any + def test_delete_by_ids_splits_into_batches(self, vector_instance): + """Test deletion with >1000 IDs triggers multiple batched calls.""" + ids = [f"doc_{i}" for i in range(2500)] + + vector_instance.delete_by_ids(ids) + + assert vector_instance._client.delete.call_count == 3 + + batches = [] + for call in vector_instance._client.delete.call_args_list: + filter_selector = call[1]["points_selector"] + field_condition = filter_selector.filter.must[0] + batches.append(field_condition.match.any) + + assert len(batches[0]) == 1000 + assert len(batches[1]) == 1000 + assert len(batches[2]) == 500 + def test_delete_by_ids_filter_structure(self, vector_instance): """Test that the filter structure is correctly constructed.""" ids = ["doc1", "doc2"] diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py b/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py new file mode 100644 index 0000000000..774264ddca --- /dev/null +++ b/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py @@ -0,0 +1,120 @@ +from unittest.mock import MagicMock, patch + +import pytest +from dify_vdb_tidb_on_qdrant.tidb_service import TidbService + + +class TestFetchQdrantEndpoint: + """Unit tests for TidbService.fetch_qdrant_endpoint.""" + + @patch.object(TidbService, "get_tidb_serverless_cluster") + def test_returns_endpoint_when_host_present(self, mock_get_cluster): + mock_get_cluster.return_value = { + "status": { + "connection_strings": { + "standard": {"host": "gateway01.us-east-1.tidbcloud.com"} + } + } + } + result = TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") + assert result == "https://qdrant-gateway01.us-east-1.tidbcloud.com" + + @patch.object(TidbService, "get_tidb_serverless_cluster") + def test_returns_none_when_cluster_response_is_none(self, mock_get_cluster): + mock_get_cluster.return_value = None + result = TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") + assert result is None + + @patch.object(TidbService, "get_tidb_serverless_cluster") + def test_returns_none_when_host_missing(self, mock_get_cluster): + mock_get_cluster.return_value = {"status": {"connection_strings": {"standard": {}}}} + result = TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") + assert result is None + + @patch.object(TidbService, "get_tidb_serverless_cluster") + def test_returns_none_when_status_missing(self, mock_get_cluster): + mock_get_cluster.return_value = {} + result = TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") + assert result is None + + @patch.object(TidbService, "get_tidb_serverless_cluster") + def test_returns_none_when_connection_strings_missing(self, mock_get_cluster): + mock_get_cluster.return_value = {"status": {}} + result = TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") + assert result is None + + @patch.object(TidbService, "get_tidb_serverless_cluster") + def test_returns_none_on_exception(self, mock_get_cluster): + mock_get_cluster.side_effect = RuntimeError("network error") + result = TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") + assert result is None + + @patch.object(TidbService, "get_tidb_serverless_cluster") + def test_returns_none_when_standard_key_missing(self, mock_get_cluster): + mock_get_cluster.return_value = {"status": {"connection_strings": {}}} + result = TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") + assert result is None + + +class TestCreateTidbServerlessClusterQdrantEndpoint: + """Verify that create_tidb_serverless_cluster includes qdrant_endpoint in its result.""" + + @patch.object(TidbService, "fetch_qdrant_endpoint", return_value="https://qdrant-gw.tidbcloud.com") + @patch.object(TidbService, "get_tidb_serverless_cluster") + @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service.dify_config") + def test_result_contains_qdrant_endpoint(self, mock_config, mock_http, mock_get_cluster, mock_fetch_ep): + mock_config.TIDB_SPEND_LIMIT = 10 + mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"}) + mock_get_cluster.return_value = {"state": "ACTIVE", "userPrefix": "pfx"} + + result = TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1") + + assert result is not None + assert result["qdrant_endpoint"] == "https://qdrant-gw.tidbcloud.com" + mock_fetch_ep.assert_called_once_with("url", "pub", "priv", "c-1") + + @patch.object(TidbService, "fetch_qdrant_endpoint", return_value=None) + @patch.object(TidbService, "get_tidb_serverless_cluster") + @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service.dify_config") + def test_result_qdrant_endpoint_none_when_fetch_fails(self, mock_config, mock_http, mock_get_cluster, mock_fetch_ep): + mock_config.TIDB_SPEND_LIMIT = 10 + mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"}) + mock_get_cluster.return_value = {"state": "ACTIVE", "userPrefix": "pfx"} + + result = TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1") + + assert result is not None + assert result["qdrant_endpoint"] is None + + +class TestBatchCreateTidbServerlessClusterQdrantEndpoint: + """Verify that batch_create includes qdrant_endpoint per cluster.""" + + @patch.object(TidbService, "fetch_qdrant_endpoint", return_value="https://qdrant-gw.tidbcloud.com") + @patch("dify_vdb_tidb_on_qdrant.tidb_service.redis_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") + @patch("dify_vdb_tidb_on_qdrant.tidb_service.dify_config") + def test_batch_result_contains_qdrant_endpoint(self, mock_config, mock_http, mock_redis, mock_fetch_ep): + mock_config.TIDB_SPEND_LIMIT = 10 + cluster_name = "abc123" + mock_http.post.return_value = MagicMock( + status_code=200, + json=lambda: {"clusters": [{"clusterId": "c-1", "displayName": cluster_name}]}, + ) + mock_redis.setex = MagicMock() + mock_redis.get.return_value = b"password123" + + result = TidbService.batch_create_tidb_serverless_cluster( + batch_size=1, + project_id="proj", + api_url="url", + iam_url="iam", + public_key="pub", + private_key="priv", + region="us-east-1", + ) + + assert len(result) == 1 + assert result[0]["qdrant_endpoint"] == "https://qdrant-gw.tidbcloud.com" From b7b3ac2b5df9cced229e7f5e46e324f9aeb5e811 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:29:37 +0000 Subject: [PATCH 049/103] [autofix.ci] apply automated fixes --- .../tests/unit_tests/test_tidb_service.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py b/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py index 774264ddca..e8a013efca 100644 --- a/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py +++ b/api/providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py @@ -1,6 +1,5 @@ from unittest.mock import MagicMock, patch -import pytest from dify_vdb_tidb_on_qdrant.tidb_service import TidbService @@ -10,11 +9,7 @@ class TestFetchQdrantEndpoint: @patch.object(TidbService, "get_tidb_serverless_cluster") def test_returns_endpoint_when_host_present(self, mock_get_cluster): mock_get_cluster.return_value = { - "status": { - "connection_strings": { - "standard": {"host": "gateway01.us-east-1.tidbcloud.com"} - } - } + "status": {"connection_strings": {"standard": {"host": "gateway01.us-east-1.tidbcloud.com"}}} } result = TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") assert result == "https://qdrant-gateway01.us-east-1.tidbcloud.com" @@ -78,7 +73,9 @@ class TestCreateTidbServerlessClusterQdrantEndpoint: @patch.object(TidbService, "get_tidb_serverless_cluster") @patch("dify_vdb_tidb_on_qdrant.tidb_service._tidb_http_client") @patch("dify_vdb_tidb_on_qdrant.tidb_service.dify_config") - def test_result_qdrant_endpoint_none_when_fetch_fails(self, mock_config, mock_http, mock_get_cluster, mock_fetch_ep): + def test_result_qdrant_endpoint_none_when_fetch_fails( + self, mock_config, mock_http, mock_get_cluster, mock_fetch_ep + ): mock_config.TIDB_SPEND_LIMIT = 10 mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"}) mock_get_cluster.return_value = {"state": "ACTIVE", "userPrefix": "pfx"} From 736880e046581ca5b0b65441cee1c1e96cc5caac Mon Sep 17 00:00:00 2001 From: Blackoutta <37723456+Blackoutta@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:31:41 +0800 Subject: [PATCH 050/103] feat: support configurable redis key prefix (#35139) --- api/.env.example | 3 + api/configs/middleware/cache/redis_config.py | 5 + api/extensions/ext_celery.py | 24 +- api/extensions/ext_redis.py | 217 ++++++++++++------ api/extensions/redis_names.py | 32 +++ api/libs/broadcast_channel/redis/channel.py | 6 +- .../redis/sharded_channel.py | 6 +- .../redis/streams_channel.py | 3 +- api/libs/db_migration_lock.py | 5 +- api/tests/integration_tests/.env.example | 1 + .../unit_tests/configs/test_dify_config.py | 35 +++ .../unit_tests/extensions/test_celery_ssl.py | 87 +++++++ .../extensions/test_pubsub_channel.py | 2 + api/tests/unit_tests/extensions/test_redis.py | 101 +++++++- .../redis/test_channel_unit_tests.py | 44 ++++ .../redis/test_streams_channel_unit_tests.py | 20 ++ docker/.env.example | 3 + docker/README.md | 1 + docker/docker-compose.yaml | 1 + 19 files changed, 522 insertions(+), 74 deletions(-) create mode 100644 api/extensions/redis_names.py diff --git a/api/.env.example b/api/.env.example index a04a18944a..beb820e797 100644 --- a/api/.env.example +++ b/api/.env.example @@ -57,6 +57,9 @@ REDIS_SSL_CERTFILE= REDIS_SSL_KEYFILE= # Path to client private key file for SSL authentication REDIS_DB=0 +# Optional global prefix for Redis keys, topics, streams, and Celery Redis transport artifacts. +# Leave empty to preserve current unprefixed behavior. +REDIS_KEY_PREFIX= # redis Sentinel configuration. REDIS_USE_SENTINEL=false diff --git a/api/configs/middleware/cache/redis_config.py b/api/configs/middleware/cache/redis_config.py index b49275758a..2def0a0d4e 100644 --- a/api/configs/middleware/cache/redis_config.py +++ b/api/configs/middleware/cache/redis_config.py @@ -32,6 +32,11 @@ class RedisConfig(BaseSettings): default=0, ) + REDIS_KEY_PREFIX: str = Field( + description="Optional global prefix for Redis keys, topics, and transport artifacts", + default="", + ) + REDIS_USE_SSL: bool = Field( description="Enable SSL/TLS for the Redis connection", default=False, diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py index 86b0550187..340f514fcc 100644 --- a/api/extensions/ext_celery.py +++ b/api/extensions/ext_celery.py @@ -9,6 +9,7 @@ from typing_extensions import TypedDict from configs import dify_config from dify_app import DifyApp +from extensions.redis_names import normalize_redis_key_prefix class _CelerySentinelKwargsDict(TypedDict): @@ -16,9 +17,10 @@ class _CelerySentinelKwargsDict(TypedDict): password: str | None -class CelerySentinelTransportDict(TypedDict): +class CelerySentinelTransportDict(TypedDict, total=False): master_name: str | None sentinel_kwargs: _CelerySentinelKwargsDict + global_keyprefix: str class CelerySSLOptionsDict(TypedDict): @@ -61,15 +63,31 @@ def get_celery_ssl_options() -> CelerySSLOptionsDict | None: def get_celery_broker_transport_options() -> CelerySentinelTransportDict | dict[str, Any]: """Get broker transport options (e.g. Redis Sentinel) for Celery connections.""" + transport_options: CelerySentinelTransportDict | dict[str, Any] if dify_config.CELERY_USE_SENTINEL: - return CelerySentinelTransportDict( + transport_options = CelerySentinelTransportDict( master_name=dify_config.CELERY_SENTINEL_MASTER_NAME, sentinel_kwargs=_CelerySentinelKwargsDict( socket_timeout=dify_config.CELERY_SENTINEL_SOCKET_TIMEOUT, password=dify_config.CELERY_SENTINEL_PASSWORD, ), ) - return {} + else: + transport_options = {} + + global_keyprefix = get_celery_redis_global_keyprefix() + if global_keyprefix: + transport_options["global_keyprefix"] = global_keyprefix + + return transport_options + + +def get_celery_redis_global_keyprefix() -> str | None: + """Return the Redis transport prefix for Celery when namespace isolation is enabled.""" + normalized_prefix = normalize_redis_key_prefix(dify_config.REDIS_KEY_PREFIX) + if not normalized_prefix: + return None + return f"{normalized_prefix}:" def init_app(app: DifyApp) -> Celery: diff --git a/api/extensions/ext_redis.py b/api/extensions/ext_redis.py index 20f05b8b9e..9f7f73765e 100644 --- a/api/extensions/ext_redis.py +++ b/api/extensions/ext_redis.py @@ -3,7 +3,7 @@ import logging import ssl from collections.abc import Callable from datetime import timedelta -from typing import TYPE_CHECKING, Any, Union +from typing import Any, Union, cast import redis from redis import RedisError @@ -18,17 +18,26 @@ from typing_extensions import TypedDict from configs import dify_config from dify_app import DifyApp +from extensions.redis_names import ( + normalize_redis_key_prefix, + serialize_redis_name, + serialize_redis_name_arg, + serialize_redis_name_args, +) from libs.broadcast_channel.channel import BroadcastChannel as BroadcastChannelProtocol from libs.broadcast_channel.redis.channel import BroadcastChannel as RedisBroadcastChannel from libs.broadcast_channel.redis.sharded_channel import ShardedRedisBroadcastChannel from libs.broadcast_channel.redis.streams_channel import StreamsBroadcastChannel -if TYPE_CHECKING: - from redis.lock import Lock - logger = logging.getLogger(__name__) +_normalize_redis_key_prefix = normalize_redis_key_prefix +_serialize_redis_name = serialize_redis_name +_serialize_redis_name_arg = serialize_redis_name_arg +_serialize_redis_name_args = serialize_redis_name_args + + class RedisClientWrapper: """ A wrapper class for the Redis client that addresses the issue where the global @@ -59,68 +68,148 @@ class RedisClientWrapper: if self._client is None: self._client = client - if TYPE_CHECKING: - # Type hints for IDE support and static analysis - # These are not executed at runtime but provide type information - def get(self, name: str | bytes) -> Any: ... - - def set( - self, - name: str | bytes, - value: Any, - ex: int | None = None, - px: int | None = None, - nx: bool = False, - xx: bool = False, - keepttl: bool = False, - get: bool = False, - exat: int | None = None, - pxat: int | None = None, - ) -> Any: ... - - def setex(self, name: str | bytes, time: int | timedelta, value: Any) -> Any: ... - def setnx(self, name: str | bytes, value: Any) -> Any: ... - def delete(self, *names: str | bytes) -> Any: ... - def incr(self, name: str | bytes, amount: int = 1) -> Any: ... - def expire( - self, - name: str | bytes, - time: int | timedelta, - nx: bool = False, - xx: bool = False, - gt: bool = False, - lt: bool = False, - ) -> Any: ... - def lock( - self, - name: str, - timeout: float | None = None, - sleep: float = 0.1, - blocking: bool = True, - blocking_timeout: float | None = None, - thread_local: bool = True, - ) -> Lock: ... - def zadd( - self, - name: str | bytes, - mapping: dict[str | bytes | int | float, float | int | str | bytes], - nx: bool = False, - xx: bool = False, - ch: bool = False, - incr: bool = False, - gt: bool = False, - lt: bool = False, - ) -> Any: ... - def zremrangebyscore(self, name: str | bytes, min: float | str, max: float | str) -> Any: ... - def zcard(self, name: str | bytes) -> Any: ... - def getdel(self, name: str | bytes) -> Any: ... - def pubsub(self) -> PubSub: ... - def pipeline(self, transaction: bool = True, shard_hint: str | None = None) -> Any: ... - - def __getattr__(self, item: str) -> Any: + def _require_client(self) -> redis.Redis | RedisCluster: if self._client is None: raise RuntimeError("Redis client is not initialized. Call init_app first.") - return getattr(self._client, item) + return self._client + + def _get_prefix(self) -> str: + return dify_config.REDIS_KEY_PREFIX + + def get(self, name: str | bytes) -> Any: + return self._require_client().get(_serialize_redis_name_arg(name, self._get_prefix())) + + def set( + self, + name: str | bytes, + value: Any, + ex: int | None = None, + px: int | None = None, + nx: bool = False, + xx: bool = False, + keepttl: bool = False, + get: bool = False, + exat: int | None = None, + pxat: int | None = None, + ) -> Any: + return self._require_client().set( + _serialize_redis_name_arg(name, self._get_prefix()), + value, + ex=ex, + px=px, + nx=nx, + xx=xx, + keepttl=keepttl, + get=get, + exat=exat, + pxat=pxat, + ) + + def setex(self, name: str | bytes, time: int | timedelta, value: Any) -> Any: + return self._require_client().setex(_serialize_redis_name_arg(name, self._get_prefix()), time, value) + + def setnx(self, name: str | bytes, value: Any) -> Any: + return self._require_client().setnx(_serialize_redis_name_arg(name, self._get_prefix()), value) + + def delete(self, *names: str | bytes) -> Any: + return self._require_client().delete(*_serialize_redis_name_args(names, self._get_prefix())) + + def incr(self, name: str | bytes, amount: int = 1) -> Any: + return self._require_client().incr(_serialize_redis_name_arg(name, self._get_prefix()), amount) + + def expire( + self, + name: str | bytes, + time: int | timedelta, + nx: bool = False, + xx: bool = False, + gt: bool = False, + lt: bool = False, + ) -> Any: + return self._require_client().expire( + _serialize_redis_name_arg(name, self._get_prefix()), + time, + nx=nx, + xx=xx, + gt=gt, + lt=lt, + ) + + def exists(self, *names: str | bytes) -> Any: + return self._require_client().exists(*_serialize_redis_name_args(names, self._get_prefix())) + + def ttl(self, name: str | bytes) -> Any: + return self._require_client().ttl(_serialize_redis_name_arg(name, self._get_prefix())) + + def getdel(self, name: str | bytes) -> Any: + return self._require_client().getdel(_serialize_redis_name_arg(name, self._get_prefix())) + + def lock( + self, + name: str, + timeout: float | None = None, + sleep: float = 0.1, + blocking: bool = True, + blocking_timeout: float | None = None, + thread_local: bool = True, + ) -> Any: + return self._require_client().lock( + _serialize_redis_name(name, self._get_prefix()), + timeout=timeout, + sleep=sleep, + blocking=blocking, + blocking_timeout=blocking_timeout, + thread_local=thread_local, + ) + + def hset(self, name: str | bytes, *args: Any, **kwargs: Any) -> Any: + return self._require_client().hset(_serialize_redis_name_arg(name, self._get_prefix()), *args, **kwargs) + + def hgetall(self, name: str | bytes) -> Any: + return self._require_client().hgetall(_serialize_redis_name_arg(name, self._get_prefix())) + + def hdel(self, name: str | bytes, *keys: str | bytes) -> Any: + return self._require_client().hdel(_serialize_redis_name_arg(name, self._get_prefix()), *keys) + + def hlen(self, name: str | bytes) -> Any: + return self._require_client().hlen(_serialize_redis_name_arg(name, self._get_prefix())) + + def zadd( + self, + name: str | bytes, + mapping: dict[str | bytes | int | float, float | int | str | bytes], + nx: bool = False, + xx: bool = False, + ch: bool = False, + incr: bool = False, + gt: bool = False, + lt: bool = False, + ) -> Any: + return self._require_client().zadd( + _serialize_redis_name_arg(name, self._get_prefix()), + cast(Any, mapping), + nx=nx, + xx=xx, + ch=ch, + incr=incr, + gt=gt, + lt=lt, + ) + + def zremrangebyscore(self, name: str | bytes, min: float | str, max: float | str) -> Any: + return self._require_client().zremrangebyscore(_serialize_redis_name_arg(name, self._get_prefix()), min, max) + + def zcard(self, name: str | bytes) -> Any: + return self._require_client().zcard(_serialize_redis_name_arg(name, self._get_prefix())) + + def pubsub(self) -> PubSub: + return self._require_client().pubsub() + + def pipeline(self, transaction: bool = True, shard_hint: str | None = None) -> Any: + return self._require_client().pipeline(transaction=transaction, shard_hint=shard_hint) + + def __getattr__(self, item: str) -> Any: + return getattr(self._require_client(), item) redis_client: RedisClientWrapper = RedisClientWrapper() diff --git a/api/extensions/redis_names.py b/api/extensions/redis_names.py new file mode 100644 index 0000000000..9e63416daf --- /dev/null +++ b/api/extensions/redis_names.py @@ -0,0 +1,32 @@ +from configs import dify_config + + +def normalize_redis_key_prefix(prefix: str | None) -> str: + """Normalize the configured Redis key prefix for consistent runtime use.""" + if prefix is None: + return "" + return prefix.strip() + + +def get_redis_key_prefix() -> str: + """Read and normalize the current Redis key prefix from config.""" + return normalize_redis_key_prefix(dify_config.REDIS_KEY_PREFIX) + + +def serialize_redis_name(name: str, prefix: str | None = None) -> str: + """Convert a logical Redis name into the physical name used in Redis.""" + normalized_prefix = get_redis_key_prefix() if prefix is None else normalize_redis_key_prefix(prefix) + if not normalized_prefix: + return name + return f"{normalized_prefix}:{name}" + + +def serialize_redis_name_arg(name: str | bytes, prefix: str | None = None) -> str | bytes: + """Prefix string Redis names while preserving bytes inputs unchanged.""" + if isinstance(name, bytes): + return name + return serialize_redis_name(name, prefix) + + +def serialize_redis_name_args(names: tuple[str | bytes, ...], prefix: str | None = None) -> tuple[str | bytes, ...]: + return tuple(serialize_redis_name_arg(name, prefix) for name in names) diff --git a/api/libs/broadcast_channel/redis/channel.py b/api/libs/broadcast_channel/redis/channel.py index 36aa1cd3e8..b76a23eb3c 100644 --- a/api/libs/broadcast_channel/redis/channel.py +++ b/api/libs/broadcast_channel/redis/channel.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Any +from extensions.redis_names import serialize_redis_name from libs.broadcast_channel.channel import Producer, Subscriber, Subscription from redis import Redis, RedisCluster @@ -32,12 +33,13 @@ class Topic: def __init__(self, redis_client: Redis | RedisCluster, topic: str): self._client = redis_client self._topic = topic + self._redis_topic = serialize_redis_name(topic) def as_producer(self) -> Producer: return self def publish(self, payload: bytes) -> None: - self._client.publish(self._topic, payload) + self._client.publish(self._redis_topic, payload) def as_subscriber(self) -> Subscriber: return self @@ -46,7 +48,7 @@ class Topic: return _RedisSubscription( client=self._client, pubsub=self._client.pubsub(), - topic=self._topic, + topic=self._redis_topic, ) diff --git a/api/libs/broadcast_channel/redis/sharded_channel.py b/api/libs/broadcast_channel/redis/sharded_channel.py index dddc92d099..919d8d622e 100644 --- a/api/libs/broadcast_channel/redis/sharded_channel.py +++ b/api/libs/broadcast_channel/redis/sharded_channel.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Any +from extensions.redis_names import serialize_redis_name from libs.broadcast_channel.channel import Producer, Subscriber, Subscription from redis import Redis, RedisCluster @@ -30,12 +31,13 @@ class ShardedTopic: def __init__(self, redis_client: Redis | RedisCluster, topic: str): self._client = redis_client self._topic = topic + self._redis_topic = serialize_redis_name(topic) def as_producer(self) -> Producer: return self def publish(self, payload: bytes) -> None: - self._client.spublish(self._topic, payload) # type: ignore[attr-defined,union-attr] + self._client.spublish(self._redis_topic, payload) # type: ignore[attr-defined,union-attr] def as_subscriber(self) -> Subscriber: return self @@ -44,7 +46,7 @@ class ShardedTopic: return _RedisShardedSubscription( client=self._client, pubsub=self._client.pubsub(), - topic=self._topic, + topic=self._redis_topic, ) diff --git a/api/libs/broadcast_channel/redis/streams_channel.py b/api/libs/broadcast_channel/redis/streams_channel.py index 983f785027..55ff6cd4f9 100644 --- a/api/libs/broadcast_channel/redis/streams_channel.py +++ b/api/libs/broadcast_channel/redis/streams_channel.py @@ -6,6 +6,7 @@ import threading from collections.abc import Iterator from typing import Self +from extensions.redis_names import serialize_redis_name from libs.broadcast_channel.channel import Producer, Subscriber, Subscription from libs.broadcast_channel.exc import SubscriptionClosedError from redis import Redis, RedisCluster @@ -35,7 +36,7 @@ class StreamsTopic: def __init__(self, redis_client: Redis | RedisCluster, topic: str, *, retention_seconds: int = 600): self._client = redis_client self._topic = topic - self._key = f"stream:{topic}" + self._key = serialize_redis_name(f"stream:{topic}") self._retention_seconds = retention_seconds self.max_length = 5000 diff --git a/api/libs/db_migration_lock.py b/api/libs/db_migration_lock.py index ca8956e397..b5fe38342a 100644 --- a/api/libs/db_migration_lock.py +++ b/api/libs/db_migration_lock.py @@ -103,7 +103,10 @@ class DbMigrationAutoRenewLock: timeout=self._ttl_seconds, thread_local=False, ) - acquired = bool(self._lock.acquire(*args, **kwargs)) + lock = self._lock + if lock is None: + raise RuntimeError("Redis lock initialization failed.") + acquired = bool(lock.acquire(*args, **kwargs)) self._acquired = acquired if acquired: self._start_heartbeat() diff --git a/api/tests/integration_tests/.env.example b/api/tests/integration_tests/.env.example index f84d39aeb5..c07ab6d6bf 100644 --- a/api/tests/integration_tests/.env.example +++ b/api/tests/integration_tests/.env.example @@ -33,6 +33,7 @@ REDIS_USERNAME= REDIS_PASSWORD=difyai123456 REDIS_USE_SSL=false REDIS_DB=0 +REDIS_KEY_PREFIX= # PostgreSQL database configuration DB_USERNAME=postgres diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index 3089750c3e..bad246a4bb 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -236,6 +236,41 @@ def test_pubsub_redis_url_required_when_default_unavailable(monkeypatch: pytest. _ = DifyConfig().normalized_pubsub_redis_url +def test_dify_config_exposes_redis_key_prefix_default(monkeypatch: pytest.MonkeyPatch): + os.environ.clear() + + monkeypatch.setenv("CONSOLE_API_URL", "https://example.com") + monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com") + monkeypatch.setenv("DB_TYPE", "postgresql") + monkeypatch.setenv("DB_USERNAME", "postgres") + monkeypatch.setenv("DB_PASSWORD", "postgres") + monkeypatch.setenv("DB_HOST", "localhost") + monkeypatch.setenv("DB_PORT", "5432") + monkeypatch.setenv("DB_DATABASE", "dify") + + config = DifyConfig(_env_file=None) + + assert config.REDIS_KEY_PREFIX == "" + + +def test_dify_config_reads_redis_key_prefix_from_env(monkeypatch: pytest.MonkeyPatch): + os.environ.clear() + + monkeypatch.setenv("CONSOLE_API_URL", "https://example.com") + monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com") + monkeypatch.setenv("DB_TYPE", "postgresql") + monkeypatch.setenv("DB_USERNAME", "postgres") + monkeypatch.setenv("DB_PASSWORD", "postgres") + monkeypatch.setenv("DB_HOST", "localhost") + monkeypatch.setenv("DB_PORT", "5432") + monkeypatch.setenv("DB_DATABASE", "dify") + monkeypatch.setenv("REDIS_KEY_PREFIX", "enterprise-a") + + config = DifyConfig(_env_file=None) + + assert config.REDIS_KEY_PREFIX == "enterprise-a" + + @pytest.mark.parametrize( ("broker_url", "expected_host", "expected_port", "expected_username", "expected_password", "expected_db"), [ diff --git a/api/tests/unit_tests/extensions/test_celery_ssl.py b/api/tests/unit_tests/extensions/test_celery_ssl.py index 81687ce5f8..366e45d86d 100644 --- a/api/tests/unit_tests/extensions/test_celery_ssl.py +++ b/api/tests/unit_tests/extensions/test_celery_ssl.py @@ -7,6 +7,47 @@ from unittest.mock import MagicMock, patch class TestCelerySSLConfiguration: """Test suite for Celery SSL configuration.""" + def test_get_celery_broker_transport_options_includes_global_keyprefix_for_redis(self): + mock_config = MagicMock() + mock_config.CELERY_USE_SENTINEL = False + mock_config.REDIS_KEY_PREFIX = "enterprise-a" + + with patch("extensions.ext_celery.dify_config", mock_config): + from extensions.ext_celery import get_celery_broker_transport_options + + result = get_celery_broker_transport_options() + + assert result["global_keyprefix"] == "enterprise-a:" + + def test_get_celery_broker_transport_options_omits_global_keyprefix_when_prefix_empty(self): + mock_config = MagicMock() + mock_config.CELERY_USE_SENTINEL = False + mock_config.REDIS_KEY_PREFIX = " " + + with patch("extensions.ext_celery.dify_config", mock_config): + from extensions.ext_celery import get_celery_broker_transport_options + + result = get_celery_broker_transport_options() + + assert "global_keyprefix" not in result + + def test_get_celery_broker_transport_options_keeps_sentinel_and_adds_global_keyprefix(self): + mock_config = MagicMock() + mock_config.CELERY_USE_SENTINEL = True + mock_config.CELERY_SENTINEL_MASTER_NAME = "mymaster" + mock_config.CELERY_SENTINEL_SOCKET_TIMEOUT = 0.1 + mock_config.CELERY_SENTINEL_PASSWORD = "secret" + mock_config.REDIS_KEY_PREFIX = "enterprise-a" + + with patch("extensions.ext_celery.dify_config", mock_config): + from extensions.ext_celery import get_celery_broker_transport_options + + result = get_celery_broker_transport_options() + + assert result["master_name"] == "mymaster" + assert result["sentinel_kwargs"]["password"] == "secret" + assert result["global_keyprefix"] == "enterprise-a:" + def test_get_celery_ssl_options_when_ssl_disabled(self): """Test SSL options when BROKER_USE_SSL is False.""" from configs import DifyConfig @@ -151,3 +192,49 @@ class TestCelerySSLConfiguration: # Check that SSL is also applied to Redis backend assert "redis_backend_use_ssl" in celery_app.conf assert celery_app.conf["redis_backend_use_ssl"] is not None + + def test_celery_init_applies_global_keyprefix_to_broker_and_backend_transport(self): + mock_config = MagicMock() + mock_config.BROKER_USE_SSL = False + mock_config.REDIS_KEY_PREFIX = "enterprise-a" + mock_config.HUMAN_INPUT_TIMEOUT_TASK_INTERVAL = 1 + mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0" + mock_config.CELERY_BACKEND = "redis" + mock_config.CELERY_RESULT_BACKEND = "redis://localhost:6379/0" + mock_config.CELERY_USE_SENTINEL = False + mock_config.LOG_FORMAT = "%(message)s" + mock_config.LOG_TZ = "UTC" + mock_config.LOG_FILE = None + mock_config.CELERY_TASK_ANNOTATIONS = {} + + mock_config.CELERY_BEAT_SCHEDULER_TIME = 1 + mock_config.ENABLE_CLEAN_EMBEDDING_CACHE_TASK = False + mock_config.ENABLE_CLEAN_UNUSED_DATASETS_TASK = False + mock_config.ENABLE_CREATE_TIDB_SERVERLESS_TASK = False + mock_config.ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK = False + mock_config.ENABLE_CLEAN_MESSAGES = False + mock_config.ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK = False + mock_config.ENABLE_DATASETS_QUEUE_MONITOR = False + mock_config.ENABLE_HUMAN_INPUT_TIMEOUT_TASK = False + mock_config.ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK = False + mock_config.MARKETPLACE_ENABLED = False + mock_config.WORKFLOW_LOG_CLEANUP_ENABLED = False + mock_config.ENABLE_WORKFLOW_RUN_CLEANUP_TASK = False + mock_config.ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK = False + mock_config.WORKFLOW_SCHEDULE_POLLER_INTERVAL = 1 + mock_config.ENABLE_TRIGGER_PROVIDER_REFRESH_TASK = False + mock_config.TRIGGER_PROVIDER_REFRESH_INTERVAL = 15 + mock_config.ENABLE_API_TOKEN_LAST_USED_UPDATE_TASK = False + mock_config.API_TOKEN_LAST_USED_UPDATE_INTERVAL = 30 + mock_config.ENTERPRISE_ENABLED = False + mock_config.ENTERPRISE_TELEMETRY_ENABLED = False + + with patch("extensions.ext_celery.dify_config", mock_config): + from dify_app import DifyApp + from extensions.ext_celery import init_app + + app = DifyApp(__name__) + celery_app = init_app(app) + + assert celery_app.conf["broker_transport_options"]["global_keyprefix"] == "enterprise-a:" + assert celery_app.conf["result_backend_transport_options"]["global_keyprefix"] == "enterprise-a:" diff --git a/api/tests/unit_tests/extensions/test_pubsub_channel.py b/api/tests/unit_tests/extensions/test_pubsub_channel.py index a5b41a7266..926c406ad4 100644 --- a/api/tests/unit_tests/extensions/test_pubsub_channel.py +++ b/api/tests/unit_tests/extensions/test_pubsub_channel.py @@ -6,6 +6,7 @@ from libs.broadcast_channel.redis.sharded_channel import ShardedRedisBroadcastCh def test_get_pubsub_broadcast_channel_defaults_to_pubsub(monkeypatch): monkeypatch.setattr(dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "pubsub") + monkeypatch.setattr(ext_redis, "_pubsub_redis_client", object()) channel = ext_redis.get_pubsub_broadcast_channel() @@ -14,6 +15,7 @@ def test_get_pubsub_broadcast_channel_defaults_to_pubsub(monkeypatch): def test_get_pubsub_broadcast_channel_sharded(monkeypatch): monkeypatch.setattr(dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "sharded") + monkeypatch.setattr(ext_redis, "_pubsub_redis_client", object()) channel = ext_redis.get_pubsub_broadcast_channel() diff --git a/api/tests/unit_tests/extensions/test_redis.py b/api/tests/unit_tests/extensions/test_redis.py index 5e9be4ab9b..21248439bf 100644 --- a/api/tests/unit_tests/extensions/test_redis.py +++ b/api/tests/unit_tests/extensions/test_redis.py @@ -1,12 +1,15 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from redis import RedisError from redis.retry import Retry from extensions.ext_redis import ( + RedisClientWrapper, _get_base_redis_params, _get_cluster_connection_health_params, _get_connection_health_params, + _normalize_redis_key_prefix, + _serialize_redis_name, redis_fallback, ) @@ -123,3 +126,99 @@ class TestRedisFallback: assert test_func.__name__ == "test_func" assert test_func.__doc__ == "Test function docstring" + + +class TestRedisKeyPrefixHelpers: + def test_normalize_redis_key_prefix_trims_whitespace(self): + assert _normalize_redis_key_prefix(" enterprise-a ") == "enterprise-a" + + def test_normalize_redis_key_prefix_treats_whitespace_only_as_empty(self): + assert _normalize_redis_key_prefix(" ") == "" + + def test_serialize_redis_name_returns_original_when_prefix_empty(self): + assert _serialize_redis_name("model_lb_index:test", "") == "model_lb_index:test" + + def test_serialize_redis_name_adds_single_colon_separator(self): + assert _serialize_redis_name("model_lb_index:test", "enterprise-a") == "enterprise-a:model_lb_index:test" + + +class TestRedisClientWrapperKeyPrefix: + def test_wrapper_get_prefixes_string_keys(self): + mock_client = MagicMock() + wrapper = RedisClientWrapper() + wrapper.initialize(mock_client) + + with patch("extensions.ext_redis.dify_config") as mock_config: + mock_config.REDIS_KEY_PREFIX = "enterprise-a" + + wrapper.get("oauth_state:abc") + + mock_client.get.assert_called_once_with("enterprise-a:oauth_state:abc") + + def test_wrapper_delete_prefixes_multiple_keys(self): + mock_client = MagicMock() + wrapper = RedisClientWrapper() + wrapper.initialize(mock_client) + + with patch("extensions.ext_redis.dify_config") as mock_config: + mock_config.REDIS_KEY_PREFIX = "enterprise-a" + + wrapper.delete("key:a", "key:b") + + mock_client.delete.assert_called_once_with("enterprise-a:key:a", "enterprise-a:key:b") + + def test_wrapper_lock_prefixes_lock_name(self): + mock_client = MagicMock() + wrapper = RedisClientWrapper() + wrapper.initialize(mock_client) + + with patch("extensions.ext_redis.dify_config") as mock_config: + mock_config.REDIS_KEY_PREFIX = "enterprise-a" + + wrapper.lock("resource-lock", timeout=10) + + mock_client.lock.assert_called_once() + args, kwargs = mock_client.lock.call_args + assert args == ("enterprise-a:resource-lock",) + assert kwargs["timeout"] == 10 + + def test_wrapper_hash_operations_prefix_key_name(self): + mock_client = MagicMock() + wrapper = RedisClientWrapper() + wrapper.initialize(mock_client) + + with patch("extensions.ext_redis.dify_config") as mock_config: + mock_config.REDIS_KEY_PREFIX = "enterprise-a" + + wrapper.hset("hash:key", "field", "value") + wrapper.hgetall("hash:key") + + mock_client.hset.assert_called_once_with("enterprise-a:hash:key", "field", "value") + mock_client.hgetall.assert_called_once_with("enterprise-a:hash:key") + + def test_wrapper_zadd_prefixes_sorted_set_name(self): + mock_client = MagicMock() + wrapper = RedisClientWrapper() + wrapper.initialize(mock_client) + + with patch("extensions.ext_redis.dify_config") as mock_config: + mock_config.REDIS_KEY_PREFIX = "enterprise-a" + + wrapper.zadd("zset:key", {"member": 1}) + + mock_client.zadd.assert_called_once() + args, kwargs = mock_client.zadd.call_args + assert args == ("enterprise-a:zset:key", {"member": 1}) + assert kwargs["nx"] is False + + def test_wrapper_preserves_keys_when_prefix_is_empty(self): + mock_client = MagicMock() + wrapper = RedisClientWrapper() + wrapper.initialize(mock_client) + + with patch("extensions.ext_redis.dify_config") as mock_config: + mock_config.REDIS_KEY_PREFIX = " " + + wrapper.get("plain:key") + + mock_client.get.assert_called_once_with("plain:key") diff --git a/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py b/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py index 460374b6f6..8bef01c1ed 100644 --- a/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py +++ b/api/tests/unit_tests/libs/broadcast_channel/redis/test_channel_unit_tests.py @@ -139,6 +139,28 @@ class TestTopic: mock_redis_client.publish.assert_called_once_with("test-topic", payload) + def test_publish_prefixes_regular_topic(self, mock_redis_client: MagicMock): + with patch("extensions.redis_names.dify_config") as mock_config: + mock_config.REDIS_KEY_PREFIX = "enterprise-a" + topic = Topic(mock_redis_client, "test-topic") + + topic.publish(b"test message") + + mock_redis_client.publish.assert_called_once_with("enterprise-a:test-topic", b"test message") + + def test_subscribe_prefixes_regular_topic(self, mock_redis_client: MagicMock): + with patch("extensions.redis_names.dify_config") as mock_config: + mock_config.REDIS_KEY_PREFIX = "enterprise-a" + topic = Topic(mock_redis_client, "test-topic") + + subscription = topic.subscribe() + try: + subscription._start_if_needed() + finally: + subscription.close() + + mock_redis_client.pubsub.return_value.subscribe.assert_called_once_with("enterprise-a:test-topic") + class TestShardedTopic: """Test cases for the ShardedTopic class.""" @@ -176,6 +198,15 @@ class TestShardedTopic: mock_redis_client.spublish.assert_called_once_with("test-sharded-topic", payload) + def test_publish_prefixes_sharded_topic(self, mock_redis_client: MagicMock): + with patch("extensions.redis_names.dify_config") as mock_config: + mock_config.REDIS_KEY_PREFIX = "enterprise-a" + sharded_topic = ShardedTopic(mock_redis_client, "test-sharded-topic") + + sharded_topic.publish(b"test sharded message") + + mock_redis_client.spublish.assert_called_once_with("enterprise-a:test-sharded-topic", b"test sharded message") + def test_subscribe_returns_sharded_subscription(self, sharded_topic: ShardedTopic, mock_redis_client: MagicMock): """Test that subscribe() returns a _RedisShardedSubscription instance.""" subscription = sharded_topic.subscribe() @@ -185,6 +216,19 @@ class TestShardedTopic: assert subscription._pubsub is mock_redis_client.pubsub.return_value assert subscription._topic == "test-sharded-topic" + def test_subscribe_prefixes_sharded_topic(self, mock_redis_client: MagicMock): + with patch("extensions.redis_names.dify_config") as mock_config: + mock_config.REDIS_KEY_PREFIX = "enterprise-a" + sharded_topic = ShardedTopic(mock_redis_client, "test-sharded-topic") + + subscription = sharded_topic.subscribe() + try: + subscription._start_if_needed() + finally: + subscription.close() + + mock_redis_client.pubsub.return_value.ssubscribe.assert_called_once_with("enterprise-a:test-sharded-topic") + @dataclasses.dataclass(frozen=True) class SubscriptionTestCase: diff --git a/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py b/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py index 0886b70ee5..fd9e5ca5b3 100644 --- a/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py +++ b/api/tests/unit_tests/libs/broadcast_channel/redis/test_streams_channel_unit_tests.py @@ -2,6 +2,7 @@ import threading import time from dataclasses import dataclass from typing import cast +from unittest.mock import patch import pytest @@ -150,6 +151,25 @@ class TestStreamsBroadcastChannel: # Expire called after publish assert fake_redis._expire_calls.get("stream:beta", 0) >= 1 + def test_topic_uses_prefixed_stream_key(self, fake_redis: FakeStreamsRedis): + with patch("extensions.redis_names.dify_config") as mock_config: + mock_config.REDIS_KEY_PREFIX = "enterprise-a" + + topic = StreamsBroadcastChannel(fake_redis, retention_seconds=60).topic("alpha") + + assert topic._topic == "alpha" + assert topic._key == "enterprise-a:stream:alpha" + + def test_publish_uses_prefixed_stream_key(self, fake_redis: FakeStreamsRedis): + with patch("extensions.redis_names.dify_config") as mock_config: + mock_config.REDIS_KEY_PREFIX = "enterprise-a" + topic = StreamsBroadcastChannel(fake_redis, retention_seconds=60).topic("beta") + + topic.publish(b"hello") + + assert fake_redis._store["enterprise-a:stream:beta"][0][1] == {b"data": b"hello"} + assert fake_redis._expire_calls.get("enterprise-a:stream:beta", 0) >= 1 + def test_topic_exposes_self_as_producer_and_subscriber(self, streams_channel: StreamsBroadcastChannel): topic = streams_channel.topic("producer-subscriber") diff --git a/docker/.env.example b/docker/.env.example index 4426a882f1..856b04a3df 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -351,6 +351,9 @@ REDIS_SSL_CERTFILE= REDIS_SSL_KEYFILE= # Path to client private key file for SSL authentication REDIS_DB=0 +# Optional global prefix for Redis keys, topics, streams, and Celery Redis transport artifacts. +# Leave empty to preserve current unprefixed behavior. +REDIS_KEY_PREFIX= # Optional: limit total Redis connections used by API/Worker (unset for default) # Align with API's REDIS_MAX_CONNECTIONS in configs REDIS_MAX_CONNECTIONS= diff --git a/docker/README.md b/docker/README.md index 4c40317f37..3130fa9886 100644 --- a/docker/README.md +++ b/docker/README.md @@ -88,6 +88,7 @@ The `.env.example` file provided in the Docker setup is extensive and covers a w 1. **Redis Configuration**: - `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`: Redis server connection settings. + - `REDIS_KEY_PREFIX`: Optional global namespace prefix for Redis keys, topics, streams, and Celery Redis transport artifacts. 1. **Celery Configuration**: diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 1fc1cfdf9e..c1ddba4f80 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -90,6 +90,7 @@ x-shared-env: &shared-api-worker-env REDIS_SSL_CERTFILE: ${REDIS_SSL_CERTFILE:-} REDIS_SSL_KEYFILE: ${REDIS_SSL_KEYFILE:-} REDIS_DB: ${REDIS_DB:-0} + REDIS_KEY_PREFIX: ${REDIS_KEY_PREFIX:-} REDIS_MAX_CONNECTIONS: ${REDIS_MAX_CONNECTIONS:-} REDIS_USE_SENTINEL: ${REDIS_USE_SENTINEL:-false} REDIS_SENTINELS: ${REDIS_SENTINELS:-} From d4783e8c1427975f8b90aa4dd9a01d6fd55e43d7 Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 14 Apr 2026 17:55:55 +0800 Subject: [PATCH 051/103] chore: url in tool description support clicking jump directly (#35163) --- .../tool-form/__tests__/item.spec.tsx | 26 +++++++++++ .../nodes/tool/components/tool-form/item.tsx | 45 ++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx index e5760310a9..896897a777 100644 --- a/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/tool-form/__tests__/item.spec.tsx @@ -78,6 +78,7 @@ describe('tool/tool-form/item', () => { mockUseLanguage.mockReturnValue('en_US') }) + // Text input fields render their descriptions inline above the input. it('should render text input labels and forward props to form input item', () => { const handleChange = vi.fn() const handleManageInputField = vi.fn() @@ -121,6 +122,31 @@ describe('tool/tool-form/item', () => { }) }) + // URL fragments inside descriptions should be rendered as external links. + it('should render URLs in descriptions as external links', () => { + render( + , + ) + + const link = screen.getByRole('link', { name: 'https://docs.dify.ai/tools' }) + expect(link).toHaveAttribute('href', 'https://docs.dify.ai/tools') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + expect(link.parentElement).toHaveTextContent('Visit https://docs.dify.ai/tools for docs') + }) + + // Non-text fields keep their descriptions inside the tooltip and support JSON schema preview. it('should show tooltip for non-description fields and open the schema modal', () => { const objectSchema = createSchema({ name: 'tool_config', diff --git a/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx index d83f445c2c..5011cf9486 100644 --- a/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx +++ b/web/app/components/workflow/nodes/tool/components/tool-form/item.tsx @@ -1,5 +1,5 @@ 'use client' -import type { FC } from 'react' +import type { FC, ReactNode } from 'react' import type { ToolVarInputs } from '../../types' import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { Tool } from '@/app/components/tools/types' @@ -15,6 +15,45 @@ import { useLanguage } from '@/app/components/header/account-setting/model-provi import { SchemaModal } from '@/app/components/plugins/plugin-detail-panel/tool-selector/components' import FormInputItem from '@/app/components/workflow/nodes/_base/components/form-input-item' +const URL_REGEX = /(https?:\/\/\S+)/g + +const renderDescriptionWithLinks = (description: string): ReactNode => { + const matches = [...description.matchAll(URL_REGEX)] + + if (!matches.length) + return description + + const parts: ReactNode[] = [] + let currentIndex = 0 + + matches.forEach((match, index) => { + const [url] = match + const start = match.index ?? 0 + + if (start > currentIndex) + parts.push(description.slice(currentIndex, start)) + + parts.push( + + {url} + , + ) + + currentIndex = start + url.length + }) + + if (currentIndex < description.length) + parts.push(description.slice(currentIndex)) + + return parts +} + type Props = { readOnly: boolean nodeId: string @@ -87,7 +126,9 @@ const ToolFormItem: FC = ({ )}
{showDescription && tooltip && ( -
{tooltip[language] || tooltip.en_US}
+
+ {renderDescriptionWithLinks(tooltip[language] || tooltip.en_US)} +
)}
Date: Tue, 14 Apr 2026 21:22:23 +0800 Subject: [PATCH 052/103] refactor(web): re-design button api (#35166) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../tracing/provider-config-modal.tsx | 4 +- .../(humanInputLayout)/form/[token]/form.tsx | 22 +- .../webapp-reset-password/check-code/page.tsx | 10 +- .../webapp-reset-password/page.tsx | 10 +- .../set-password/page.tsx | 10 +- .../webapp-signin/check-code/page.tsx | 10 +- .../components/mail-and-code-auth.tsx | 4 +- .../components/mail-and-password-auth.tsx | 4 +- .../webapp-signin/components/sso-auth.tsx | 2 +- .../account-page/AvatarWithEdit.tsx | 6 +- .../account-page/email-change-modal.tsx | 54 +- .../(commonLayout)/account-page/index.tsx | 32 +- .../delete-account/components/check-email.tsx | 8 +- .../delete-account/components/feed-back.tsx | 4 +- .../components/verify-email.tsx | 10 +- web/app/account/(commonLayout)/header.tsx | 6 +- web/app/account/oauth/authorize/page.tsx | 12 +- web/app/activate/activateForm.tsx | 2 +- .../__tests__/app-info-detail-panel.spec.tsx | 4 +- .../__tests__/app-operations.spec.tsx | 4 +- .../app-info/app-info-detail-panel.tsx | 12 +- .../app-sidebar/app-info/app-operations.tsx | 12 +- .../components/app-sidebar/toggle-button.tsx | 4 +- .../annotation/add-annotation-modal/index.tsx | 6 +- .../csv-uploader.tsx | 4 +- .../batch-add-annotation-modal/index.tsx | 8 +- .../edit-annotation-modal/edit-item/index.tsx | 12 +- .../app/annotation/header-opts/index.tsx | 16 +- .../add-member-or-group-pop.tsx | 8 +- .../app/app-access-control/index.tsx | 6 +- .../components/app/app-publisher/index.tsx | 4 +- .../publish-with-multiple-model.tsx | 2 +- .../components/app/app-publisher/sections.tsx | 2 +- .../app/app-publisher/version-info-modal.tsx | 10 +- .../warning-mask/cannot-query-dataset.tsx | 2 +- .../base/warning-mask/formatting-changed.tsx | 2 +- .../config-prompt/advanced-prompt-input.tsx | 12 +- .../config-prompt/confirm-add-var/index.tsx | 4 +- .../conversation-history/edit-modal.tsx | 6 +- .../app/configuration/config-prompt/index.tsx | 2 +- .../configuration/config-var/modal-foot.tsx | 2 +- .../config-vision/param-config.tsx | 2 +- .../config/agent-setting-button.tsx | 2 +- .../config/agent/agent-setting/index.tsx | 16 +- .../config/agent/agent-tools/index.tsx | 12 +- .../agent-tools/setting-built-in-tool.tsx | 18 +- .../config/automatic/automatic-btn.tsx | 2 +- .../config/automatic/get-automatic-res.tsx | 10 +- .../configuration/config/automatic/result.tsx | 4 +- .../code-generator/get-code-generator-res.tsx | 6 +- .../app/configuration/configuration-view.tsx | 2 +- .../configuration/ctrl-btn-group/index.tsx | 4 +- .../dataset-config/params-config/index.tsx | 2 +- .../dataset-config/select-dataset/index.tsx | 4 +- .../dataset-config/settings-modal/index.tsx | 16 +- .../app/configuration/debug/index.tsx | 16 +- .../prompt-value-panel/index.tsx | 8 +- .../tools/external-data-tool-modal.tsx | 8 +- .../app/create-app-dialog/app-card/index.tsx | 8 +- .../components/app/create-app-modal/index.tsx | 38 +- .../dsl-confirm-modal.tsx | 4 +- .../app/create-from-dsl-modal/index.tsx | 12 +- .../components/app/duplicate-modal/index.tsx | 10 +- .../components/app/in-site-message/index.tsx | 12 +- .../app/overview/apikey-info-panel/index.tsx | 8 +- .../app/overview/app-card-sections.tsx | 2 +- .../app/overview/customize/index.tsx | 20 +- .../app/overview/settings/index.tsx | 46 +- .../components/app/switch-app-modal/index.tsx | 10 +- .../saved-items/no-data/index.tsx | 6 +- .../components/base/app-icon-picker/index.tsx | 4 +- .../base/button/__tests__/add-button.spec.tsx | 49 - .../button/__tests__/sync-button.spec.tsx | 52 - .../base/button/add-button.stories.tsx | 52 - web/app/components/base/button/add-button.tsx | 21 - .../base/button/sync-button.stories.tsx | 57 - .../components/base/button/sync-button.tsx | 26 - .../chat-with-history/inputs-form/index.tsx | 8 +- .../sidebar/__tests__/rename-modal.spec.tsx | 10 +- .../chat/chat-with-history/sidebar/index.tsx | 4 +- .../sidebar/rename-modal.tsx | 4 +- .../human-input-content/human-input-form.tsx | 4 +- .../chat/chat/chat-input-area/operation.tsx | 2 +- web/app/components/base/chat/chat/index.tsx | 2 +- .../components/base/chat/chat/question.tsx | 6 +- .../components/base/chat/chat/try-to-ask.tsx | 6 +- .../embedded-chatbot/inputs-form/index.tsx | 8 +- .../components/base/checkbox-list/index.tsx | 14 +- .../components/base/confirm/index.stories.tsx | 2 +- web/app/components/base/confirm/index.tsx | 12 +- .../date-picker/footer.tsx | 8 +- .../time-picker/footer.tsx | 2 +- .../year-and-month-picker/footer.tsx | 2 +- web/app/components/base/drawer/index.tsx | 4 +- .../components/base/emoji-picker/index.tsx | 4 +- .../components/base/error-boundary/index.tsx | 6 +- .../annotation-reply/config-param-modal.tsx | 4 +- .../annotation-reply/index.tsx | 12 +- .../conversation-opener/index.tsx | 6 +- .../conversation-opener/modal.tsx | 12 +- .../new-feature-panel/feature-bar.tsx | 6 +- .../new-feature-panel/file-upload/index.tsx | 8 +- .../file-upload/setting-content.tsx | 4 +- .../new-feature-panel/image-upload/index.tsx | 8 +- .../new-feature-panel/moderation/index.tsx | 12 +- .../moderation/moderation-setting-modal.tsx | 18 +- .../text-to-speech/index.tsx | 10 +- .../file-from-link-or-local/index.tsx | 8 +- .../file-uploader-in-attachment/index.tsx | 2 +- .../file-image-item.tsx | 8 +- .../file-uploader-in-chat-input/file-item.tsx | 10 +- .../base/form/components/form/actions.tsx | 2 +- .../components/base/form/index.stories.tsx | 2 +- .../base/image-uploader/image-link-input.tsx | 4 +- .../base/inline-delete-confirm/index.tsx | 4 +- .../base/markdown-blocks/button.tsx | 4 +- .../components/base/markdown-blocks/form.tsx | 6 +- .../components/base/modal-like-wrap/index.tsx | 6 +- .../components/base/modal/modal.stories.tsx | 4 +- web/app/components/base/modal/modal.tsx | 12 +- .../base/notion-connector/index.tsx | 6 +- web/app/components/base/pagination/index.tsx | 14 +- .../plugins/hitl-input-block/input-field.tsx | 12 +- .../base/tag-management/tag-remove-modal.tsx | 8 +- .../components/base/ui/alert-dialog/index.tsx | 6 +- .../{ => ui}/button/__tests__/index.spec.tsx | 9 +- .../components/base/{ => ui}/button/index.css | 185 +-- .../base/{ => ui}/button/index.stories.tsx | 11 +- .../components/base/{ => ui}/button/index.tsx | 47 +- .../billing/apps-full-in-dialog/index.tsx | 8 +- .../billing/plan-upgrade-modal/index.tsx | 4 +- web/app/components/billing/plan/index.tsx | 2 +- web/app/components/billing/pricing/header.tsx | 6 +- .../components/billing/upgrade-btn/index.tsx | 2 +- .../components/chat-preview-card.tsx | 12 +- .../components/workflow-preview-card.tsx | 10 +- .../custom/custom-web-app-brand/index.tsx | 16 +- .../datasets/common/image-previewer/index.tsx | 12 +- .../image-uploader-in-chunk/image-item.tsx | 4 +- .../image-item.tsx | 4 +- .../dsl-confirm-modal.tsx | 4 +- .../create-from-dsl-modal/index.tsx | 4 +- .../datasets/create-from-pipeline/header.tsx | 6 +- .../list/template-card/actions.tsx | 2 +- .../list/template-card/details/index.tsx | 12 +- .../list/template-card/edit-pipeline-info.tsx | 10 +- .../create/embedding-process/index.tsx | 4 +- .../empty-dataset-creation-modal/index.tsx | 2 +- .../step-one/components/next-step-button.tsx | 2 +- .../components/general-chunking-options.tsx | 10 +- .../components/indexing-mode-section.tsx | 20 +- .../components/parent-child-options.tsx | 6 +- .../step-two/components/step-two-footer.tsx | 2 +- .../create/stop-embedding-modal/index.tsx | 2 +- .../website/base/__tests__/url-input.spec.tsx | 9 +- .../website/base/crawled-result-item.tsx | 4 +- .../datasets/create/website/base/header.tsx | 4 +- .../create/website/base/url-input.tsx | 3 +- .../base/__tests__/url-input.spec.tsx | 8 +- .../website/jina-reader/base/url-input.tsx | 2 +- .../datasets/create/website/no-data.tsx | 6 +- .../documents/components/documents-header.tsx | 2 +- .../documents/components/empty-element.tsx | 4 +- .../documents/components/rename-modal.tsx | 4 +- .../create-from-pipeline/actions/index.tsx | 6 +- .../base/__tests__/header.spec.tsx | 4 +- .../data-source/base/header.tsx | 4 +- .../online-drive/connect/index.tsx | 6 +- .../file-list/list/empty-search-result.tsx | 2 +- .../data-source/online-drive/header.tsx | 4 +- .../__tests__/crawled-result-item.spec.tsx | 4 +- .../base/crawled-result-item.tsx | 8 +- .../website-crawl/base/options/index.tsx | 5 +- .../create-from-pipeline/left-header.tsx | 12 +- .../preview/chunk-preview.tsx | 2 +- .../__tests__/header.spec.tsx | 4 +- .../process-documents/actions.tsx | 2 +- .../process-documents/header.tsx | 4 +- .../processing/embedding-process/index.tsx | 12 +- .../detail/batch-modal/csv-uploader.tsx | 4 +- .../documents/detail/batch-modal/index.tsx | 6 +- .../completed/common/action-buttons.tsx | 2 +- .../detail/completed/common/batch-action.tsx | 6 +- .../completed/common/regeneration-modal.tsx | 6 +- .../metadata/components/doc-type-selector.tsx | 2 +- .../documents/detail/metadata/index.tsx | 2 +- .../pipeline-settings/left-header.tsx | 12 +- .../process-documents/actions.tsx | 2 +- .../external-api/external-api-modal/index.tsx | 18 +- .../external-api/external-api-panel/index.tsx | 10 +- .../create/ExternalApiSelection.tsx | 2 +- .../external-knowledge-base/create/index.tsx | 10 +- .../datasets/extra-info/service-api/card.tsx | 10 +- .../modify-external-retrieval-modal.spec.tsx | 4 +- .../__tests__/modify-retrieval-modal.spec.tsx | 4 +- .../query-input/__tests__/index.spec.tsx | 6 +- .../components/query-input/index.tsx | 8 +- .../modify-external-retrieval-modal.tsx | 6 +- .../hit-testing/modify-retrieval-modal.tsx | 8 +- web/app/components/datasets/list/index.tsx | 6 +- .../datasets/metadata/add-metadata-button.tsx | 2 +- .../metadata/edit-metadata-batch/modal.tsx | 12 +- .../dataset-metadata-drawer.tsx | 12 +- .../metadata/metadata-document/index.tsx | 2 +- .../metadata/metadata-document/no-data.tsx | 6 +- .../datasets/rename-modal/index.tsx | 8 +- .../datasets/settings/form/index.tsx | 2 +- .../develop/secret-key/secret-key-button.tsx | 4 +- .../secret-key/secret-key-generate.tsx | 6 +- .../develop/secret-key/secret-key-modal.tsx | 6 +- web/app/components/explore/app-card/index.tsx | 12 +- web/app/components/explore/app-list/index.tsx | 6 +- .../explore/create-app-modal/index.tsx | 18 +- .../explore/try-app/app-info/index.tsx | 14 +- web/app/components/explore/try-app/index.tsx | 4 +- .../components/header/account-about/index.tsx | 4 +- .../__tests__/compliance.spec.tsx | 32 +- .../header/account-dropdown/compliance.tsx | 23 +- .../api-based-extension-page/index.tsx | 2 +- .../api-based-extension-page/item.tsx | 2 +- .../api-based-extension-page/modal.tsx | 6 +- .../data-source-page-new/configure.tsx | 4 +- .../data-source-page-new/item.tsx | 8 +- .../header/account-setting/index.tsx | 20 +- .../edit-workspace-modal/index.tsx | 4 +- .../members-page/invite-button.tsx | 2 +- .../members-page/invite-modal/index.tsx | 10 +- .../members-page/invited-modal/index.tsx | 4 +- .../transfer-ownership-modal/index.tsx | 44 +- .../model-auth/add-custom-model.tsx | 10 +- .../model-auth/authorized/index.tsx | 6 +- .../model-auth/config-model.tsx | 6 +- .../model-auth/config-provider.tsx | 4 +- .../manage-custom-model-credentials.tsx | 2 +- .../switch-credential-in-load-balancing.tsx | 2 +- .../model-provider-page/model-modal/index.tsx | 23 +- .../configuration-button.tsx | 2 +- .../presets-parameter.tsx | 4 +- .../model-selector/popup.tsx | 28 +- .../model-auth-dropdown/api-key-section.tsx | 8 +- .../model-auth-dropdown/index.tsx | 4 +- .../model-load-balancing-modal.tsx | 6 +- .../provider-added-card/priority-selector.tsx | 6 +- .../provider-card-actions.tsx | 4 +- .../system-model-selector/index.tsx | 14 +- .../plugins/install-plugin/base/installed.tsx | 2 +- .../install-bundle/steps/install.tsx | 4 +- .../install-bundle/steps/installed.tsx | 2 +- .../install-from-github/steps/loaded.tsx | 2 +- .../steps/selectPackage.tsx | 2 +- .../install-from-github/steps/setURL.tsx | 8 +- .../steps/install.tsx | 6 +- .../steps/uploading.tsx | 2 +- .../steps/install.tsx | 6 +- .../plugins/marketplace/list/card-wrapper.tsx | 6 +- .../authorize/add-api-key-button.tsx | 4 +- .../authorize/add-oauth-button.tsx | 12 +- .../authorize/oauth-client-settings.tsx | 2 +- .../authorized-in-data-source-node.tsx | 2 +- .../plugin-auth/authorized-in-node.tsx | 2 +- .../plugins/plugin-auth/authorized/index.tsx | 6 +- .../plugins/plugin-auth/authorized/item.tsx | 6 +- .../plugin-auth/plugin-auth-in-agent.tsx | 2 +- .../plugin-auth-in-datasource-node.tsx | 2 +- .../datasource-action-list.tsx | 6 +- .../detail-header/__tests__/index.spec.tsx | 4 +- .../detail-header/index.tsx | 6 +- .../plugin-detail-panel/endpoint-modal.tsx | 10 +- .../subscription-list/create/index.tsx | 6 +- .../subscription-list/create/oauth-client.tsx | 8 +- .../components/tool-credentials-form.tsx | 2 +- .../tool-selector/components/tool-item.tsx | 6 +- .../plugins/plugin-mutation-model/index.tsx | 4 +- .../plugin-page/__tests__/debug-info.spec.tsx | 4 +- .../install-plugin-dropdown.spec.tsx | 4 +- .../plugins/plugin-page/debug-info.tsx | 4 +- .../plugins/plugin-page/empty/index.tsx | 10 +- .../components/plugins/plugin-page/index.tsx | 4 +- .../plugin-page/install-plugin-dropdown.tsx | 6 +- .../components/error-plugin-item.tsx | 2 +- .../components/plugin-task-list.tsx | 4 +- .../plugins/plugin-page/plugins-panel.tsx | 4 +- web/app/components/plugins/provider-card.tsx | 6 +- .../__tests__/plugins-picker.spec.tsx | 4 +- .../__tests__/strategy-picker.spec.tsx | 4 +- .../auto-update-setting/plugins-picker.tsx | 4 +- .../auto-update-setting/strategy-picker.tsx | 4 +- .../plugins/reference-setting-modal/index.tsx | 6 +- .../__tests__/from-market-place.spec.tsx | 4 +- .../update-plugin/downgrade-warning.tsx | 6 +- .../update-plugin/from-market-place.tsx | 6 +- .../components/__tests__/conversion.spec.tsx | 4 +- ...blish-as-knowledge-pipeline-modal.spec.tsx | 4 +- .../__tests__/update-dsl-modal.spec.tsx | 8 +- .../rag-pipeline/components/conversion.tsx | 4 +- .../panel/input-field/editor/form/index.tsx | 2 +- .../components/panel/input-field/index.tsx | 6 +- .../test-run/preparation/actions/index.tsx | 2 +- .../document-processing/actions.tsx | 2 +- .../test-run/result/result-preview/index.tsx | 4 +- .../publish-as-knowledge-pipeline-modal.tsx | 10 +- .../input-field-button.tsx | 2 +- .../publisher/__tests__/popup.spec.tsx | 4 +- .../rag-pipeline-header/publisher/index.tsx | 2 +- .../rag-pipeline-header/publisher/popup.tsx | 16 +- .../components/update-dsl-modal.tsx | 11 +- .../components/version-mismatch-modal.tsx | 4 +- .../share/text-generation/result/index.tsx | 6 +- .../share/text-generation/run-batch/index.tsx | 4 +- .../run-batch/res-download/index.tsx | 2 +- .../share/text-generation/run-once/index.tsx | 8 +- .../config-credentials.tsx | 14 +- .../get-schema.tsx | 12 +- .../edit-custom-collection-modal/index.tsx | 26 +- .../edit-custom-collection-modal/test-api.tsx | 16 +- .../components/tools/mcp/detail/content.tsx | 20 +- .../components/tools/mcp/headers-input.tsx | 6 +- .../components/tools/mcp/mcp-server-modal.tsx | 10 +- .../components/tools/mcp/mcp-service-card.tsx | 12 +- web/app/components/tools/mcp/modal.tsx | 8 +- web/app/components/tools/provider/detail.tsx | 8 +- .../setting/build-in/config-credentials.tsx | 4 +- .../tools/workflow-tool/configure-button.tsx | 8 +- .../confirm-modal/__tests__/index.spec.tsx | 3 +- .../workflow-tool/confirm-modal/index.tsx | 8 +- .../components/tools/workflow-tool/index.tsx | 4 +- .../workflow-header/features-trigger.tsx | 2 +- .../block-selector/all-start-blocks.tsx | 6 +- .../workflow/block-selector/all-tools.tsx | 4 +- .../market-place-plugin/action.tsx | 6 +- .../workflow/dsl-export-confirm-modal.tsx | 16 +- .../workflow/header/chat-variable-button.tsx | 2 +- .../header/checklist/plugin-group.tsx | 4 +- .../components/workflow/header/env-button.tsx | 2 +- .../header/global-variable-button.tsx | 2 +- .../workflow/header/header-in-restoring.tsx | 4 +- .../header/header-in-view-history.tsx | 2 +- .../header/version-history-button.tsx | 4 +- .../nodes/_base/components/add-button.tsx | 2 +- .../_base/components/before-run-form/form.tsx | 7 +- .../components/before-run-form/index.tsx | 2 +- .../error-handle-type-selector.tsx | 4 +- .../components/install-plugin-button.tsx | 2 +- .../nodes/_base/components/next-step/item.tsx | 4 +- .../_base/components/next-step/operator.tsx | 4 +- .../variable/var-reference-picker.trigger.tsx | 23 +- .../workflow-panel/last-run/no-data.tsx | 4 +- .../components/workflow/nodes/code/panel.tsx | 28 +- .../nodes/data-source-empty/index.tsx | 4 +- .../nodes/data-source/before-run-form.tsx | 2 +- .../components/workflow/nodes/end/panel.tsx | 9 +- .../http/components/authorization/index.tsx | 4 +- .../nodes/http/components/curl-panel.tsx | 2 +- .../__tests__/button-style-dropdown.spec.tsx | 5 +- .../__tests__/form-content-preview.spec.tsx | 5 +- .../components/__tests__/user-action.spec.tsx | 5 +- .../components/button-style-dropdown.tsx | 2 +- .../delivery-method/email-configure-modal.tsx | 10 +- .../delivery-method/method-item.tsx | 6 +- .../recipient/member-selector.tsx | 2 +- .../delivery-method/test-email-sender.tsx | 12 +- .../delivery-method/upgrade-modal.tsx | 6 +- .../components/form-content-preview.tsx | 6 +- .../components/single-run-form.tsx | 8 +- .../human-input/components/user-action.tsx | 2 +- .../workflow/nodes/human-input/panel.tsx | 8 +- .../if-else/components/condition-add.tsx | 2 +- .../condition-list/condition-operator.tsx | 2 +- .../components/condition-number-input.tsx | 6 +- .../if-else/components/condition-wrap.tsx | 10 +- .../workflow/nodes/if-else/panel.tsx | 4 +- .../components/chunk-structure/index.tsx | 4 +- .../components/chunk-structure/selector.tsx | 4 +- .../components/add-dataset.tsx | 5 +- .../components/metadata/add-condition.tsx | 6 +- .../condition-list/condition-operator.tsx | 2 +- .../condition-list/condition-value-method.tsx | 2 +- .../metadata-filter-selector.tsx | 2 +- .../components/metadata/metadata-trigger.tsx | 4 +- .../components/retrieval-config.tsx | 4 +- .../json-importer.tsx | 10 +- .../json-schema-config.tsx | 10 +- .../generated-result.tsx | 10 +- .../json-schema-generator/prompt-editor.tsx | 14 +- .../visual-editor/add-field.tsx | 2 +- .../edit-card/advanced-actions.tsx | 2 +- .../nodes/llm/components/structure-output.tsx | 6 +- .../components/workflow/nodes/llm/panel.tsx | 11 +- .../nodes/loop/components/condition-add.tsx | 2 +- .../condition-list/condition-operator.tsx | 2 +- .../components/condition-number-input.tsx | 6 +- .../nodes/loop/components/condition-wrap.tsx | 4 +- .../components/extract-parameter/update.tsx | 9 +- .../components/workflow/nodes/start/panel.tsx | 9 +- .../nodes/template-transform/panel.tsx | 9 +- .../nodes/tool/components/tool-form/item.tsx | 10 +- .../components/trigger-form/item.tsx | 10 +- .../plugins/link-editor-plugin/component.tsx | 4 +- .../components/array-bool-list.tsx | 2 +- .../components/array-value-list.tsx | 2 +- .../components/variable-modal-trigger.tsx | 2 +- .../components/variable-modal.sections.tsx | 12 +- .../components/variable-modal.tsx | 4 +- .../panel/env-panel/variable-modal.tsx | 24 +- .../panel/env-panel/variable-trigger.tsx | 2 +- .../workflow/panel/inputs-panel.tsx | 4 +- .../context-menu/index.tsx | 2 +- .../delete-confirm-modal.tsx | 6 +- .../panel/version-history-panel/empty.tsx | 4 +- .../restore-confirm-modal.tsx | 4 +- .../workflow/panel/workflow-preview.tsx | 12 +- .../workflow/run/agent-log/agent-log-item.tsx | 8 +- .../run/agent-log/agent-log-nav-more.tsx | 4 +- .../workflow/run/agent-log/agent-log-nav.tsx | 12 +- .../iteration-log/iteration-log-trigger.tsx | 4 +- .../run/loop-log/loop-log-trigger.tsx | 4 +- .../run/retry-log/retry-log-trigger.tsx | 2 +- .../components/workflow/update-dsl-modal.tsx | 19 +- .../workflow/variable-inspect/group.tsx | 10 +- .../workflow/variable-inspect/left.tsx | 6 +- .../workflow/variable-inspect/listening.tsx | 6 +- .../education-apply/education-apply-page.tsx | 24 +- .../education-apply/expire-notice-modal.tsx | 10 +- web/app/education-apply/user-info.tsx | 8 +- .../education-apply/verify-state-modal.tsx | 12 +- .../forgot-password/ChangePasswordForm.tsx | 2 +- .../forgot-password/ForgotPasswordForm.tsx | 2 +- web/app/init/InitPasswordPopup.tsx | 4 +- web/app/install/installForm.tsx | 2 +- web/app/reset-password/check-code/page.tsx | 10 +- web/app/reset-password/page.tsx | 10 +- web/app/reset-password/set-password/page.tsx | 10 +- web/app/signin/check-code/page.tsx | 10 +- .../signin/components/mail-and-code-auth.tsx | 4 +- .../components/mail-and-password-auth.tsx | 6 +- web/app/signin/components/social-auth.tsx | 2 +- web/app/signin/components/sso-auth.tsx | 2 +- web/app/signin/invite-settings/page.tsx | 18 +- web/app/signin/one-more-step.tsx | 10 +- web/app/signup/check-code/page.tsx | 10 +- web/app/signup/components/input-mail.tsx | 10 +- web/app/signup/set-password/page.tsx | 10 +- web/app/styles/globals.css | 2 +- web/eslint-suppressions.json | 1239 ----------------- 444 files changed, 1636 insertions(+), 3169 deletions(-) delete mode 100644 web/app/components/base/button/__tests__/add-button.spec.tsx delete mode 100644 web/app/components/base/button/__tests__/sync-button.spec.tsx delete mode 100644 web/app/components/base/button/add-button.stories.tsx delete mode 100644 web/app/components/base/button/add-button.tsx delete mode 100644 web/app/components/base/button/sync-button.stories.tsx delete mode 100644 web/app/components/base/button/sync-button.tsx rename web/app/components/base/{ => ui}/button/__tests__/index.spec.tsx (93%) rename web/app/components/base/{ => ui}/button/index.css (66%) rename web/app/components/base/{ => ui}/button/index.stories.tsx (88%) rename web/app/components/base/{ => ui}/button/index.tsx (57%) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx index 72913b4934..caf6562a3e 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx @@ -5,7 +5,6 @@ import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import Button from '@/app/components/base/button' import Confirm from '@/app/components/base/confirm' import Divider from '@/app/components/base/divider' import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general' @@ -14,6 +13,7 @@ import { PortalToFollowElem, PortalToFollowElemContent, } from '@/app/components/base/portal-to-follow-elem' +import { Button } from '@/app/components/base/ui/button' import { toast } from '@/app/components/base/ui/toast' import { addTracingConfig, removeTracingConfig, updateTracingConfig } from '@/service/apps' import { docURL } from './config' @@ -621,7 +621,7 @@ const ProviderConfigModal: FC = ({
diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx index 221420aade..898dab8f4a 100644 --- a/web/app/(humanInputLayout)/form/[token]/form.tsx +++ b/web/app/(humanInputLayout)/form/[token]/form.tsx @@ -1,5 +1,5 @@ 'use client' -import type { ButtonProps } from '@/app/components/base/button' +import type { ButtonProps } from '@/app/components/base/ui/button' import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types' import type { SiteInfo } from '@/models/share' import type { HumanInputFormError } from '@/service/use-share' @@ -13,12 +13,12 @@ import * as React from 'react' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' -import Button from '@/app/components/base/button' import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item' import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time' import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-input-content/utils' import Loading from '@/app/components/base/loading' import DifyLogo from '@/app/components/base/logo/dify-logo' +import { Button } from '@/app/components/base/ui/button' import useDocumentTitle from '@/hooks/use-document-title' import { useParams } from '@/next/navigation' import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share' @@ -100,7 +100,7 @@ const FormContent = () => { if (success) { return (
-
+
@@ -109,7 +109,7 @@ const FormContent = () => {
{t('humanInput.thanks', { ns: 'share' })}
{t('humanInput.recorded', { ns: 'share' })}
-
{t('humanInput.submissionID', { id: token, ns: 'share' })}
+
{t('humanInput.submissionID', { id: token, ns: 'share' })}
{ if (expired) { return (
-
+
@@ -137,7 +137,7 @@ const FormContent = () => {
{t('humanInput.sorry', { ns: 'share' })}
{t('humanInput.expired', { ns: 'share' })}
-
{t('humanInput.submissionID', { id: token, ns: 'share' })}
+
{t('humanInput.submissionID', { id: token, ns: 'share' })}
{ if (submitted) { return (
-
+
@@ -165,7 +165,7 @@ const FormContent = () => {
{t('humanInput.sorry', { ns: 'share' })}
{t('humanInput.completed', { ns: 'share' })}
-
{t('humanInput.submissionID', { id: token, ns: 'share' })}
+
{t('humanInput.submissionID', { id: token, ns: 'share' })}
{ if (rateLimitExceeded) { return (
-
+
@@ -210,7 +210,7 @@ const FormContent = () => { if (!formData) { return (
-
+
@@ -245,7 +245,7 @@ const FormContent = () => { background={site.icon_background} imageUrl={site.icon_url} /> -
{site.title}
+
{site.title}
diff --git a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx index b71e6b4767..d19e5a7d2d 100644 --- a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx @@ -2,8 +2,8 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' +import { Button } from '@/app/components/base/ui/button' import { toast } from '@/app/components/base/ui/toast' import Countdown from '@/app/components/signin/countdown' import { useLocale } from '@/context/i18n' @@ -62,9 +62,9 @@ export default function CheckCode() {
-
+

{t('checkCode.checkYourEmail', { ns: 'login' })}

-

+

{t('checkCode.tipsPrefix', { ns: 'login' })} {email} @@ -76,7 +76,7 @@ export default function CheckCode() {

- + setVerifyCode(e.target.value)} maxLength={6} className="mt-1" placeholder={t('checkCode.verificationCodePlaceholder', { ns: 'login' }) || ''} /> @@ -88,7 +88,7 @@ export default function CheckCode() {
- {t('back', { ns: 'login' })} + {t('back', { ns: 'login' })}
) diff --git a/web/app/(shareLayout)/webapp-reset-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/page.tsx index a25b4bb4ef..cb6ece219c 100644 --- a/web/app/(shareLayout)/webapp-reset-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/page.tsx @@ -3,8 +3,8 @@ import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' +import { Button } from '@/app/components/base/ui/button' import { toast } from '@/app/components/base/ui/toast' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { emailRegex } from '@/config' @@ -64,9 +64,9 @@ export default function CheckCode() {
-
+

{t('resetPassword', { ns: 'login' })}

-

+

{t('resetPasswordDesc', { ns: 'login' })}

@@ -74,7 +74,7 @@ export default function CheckCode() {
- +
setEmail(e.target.value)} />
@@ -90,7 +90,7 @@ export default function CheckCode() {
- {t('backToLogin', { ns: 'login' })} + {t('backToLogin', { ns: 'login' })}
) diff --git a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx index bc8f651d17..5b89084ea1 100644 --- a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx @@ -3,8 +3,8 @@ import { RiCheckboxCircleFill } from '@remixicon/react' import { useCountDown } from 'ahooks' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' +import { Button } from '@/app/components/base/ui/button' import { toast } from '@/app/components/base/ui/toast' import { validPassword } from '@/config' import { useRouter, useSearchParams } from '@/next/navigation' @@ -91,7 +91,7 @@ const ChangePasswordForm = () => {

{t('changePassword', { ns: 'login' })}

-

+

{t('changePasswordTip', { ns: 'login' })}

@@ -100,7 +100,7 @@ const ChangePasswordForm = () => {
{/* Password */}
-
-
{t('error.passwordInvalid', { ns: 'login' })}
+
{t('error.passwordInvalid', { ns: 'login' })}
{/* Confirm Password */}
-
) diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx index fbd6b216df..f600dba8b2 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx @@ -1,8 +1,8 @@ import { noop } from 'es-toolkit/function' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' +import { Button } from '@/app/components/base/ui/button' import { toast } from '@/app/components/base/ui/toast' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { emailRegex } from '@/config' @@ -52,7 +52,7 @@ export default function MailAndCodeAuth() {
- +
setEmail(e.target.value)} />
diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx index 1e9355e7ba..7fe5363927 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx @@ -2,8 +2,8 @@ import { noop } from 'es-toolkit/function' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' +import { Button } from '@/app/components/base/ui/button' import { toast } from '@/app/components/base/ui/toast' import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' @@ -103,7 +103,7 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut return (
-