mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 05:41:09 +08:00
361 lines
14 KiB
Python
361 lines
14 KiB
Python
import logging
|
|
from collections.abc import Mapping, Sequence
|
|
from typing import Literal
|
|
from warnings import deprecated
|
|
|
|
from pydantic import ValidationError
|
|
|
|
from extensions.ext_redis import redis_client
|
|
from services.billing_client import billing, saas
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
EmailFreezeType = Literal["freeze", "email_domain_suspended"]
|
|
|
|
|
|
def _quota_meta(meta: Mapping[str, object] | None) -> dict[str, str]:
|
|
"""The spec types quota metadata as a string map."""
|
|
return {key: value if isinstance(value, str) else str(value) for key, value in (meta or {}).items()}
|
|
|
|
|
|
class BillingService:
|
|
# Redis key prefix for tenant plan cache
|
|
_PLAN_CACHE_KEY_PREFIX = "tenant_plan:"
|
|
# Cache TTL: 10 minutes
|
|
_PLAN_CACHE_TTL = 600
|
|
|
|
@classmethod
|
|
def get_info(cls, tenant_id: str, exclude_vector_space: bool = False) -> billing.GetSubscriptionInfoReply:
|
|
return billing.subscription_get_subscription_info(
|
|
tenant_id=tenant_id, exclude_vector_space=exclude_vector_space
|
|
)
|
|
|
|
@classmethod
|
|
def get_vector_space(cls, tenant_id: str, bypass_cache: bool = False) -> billing.VectorSpaceQuota:
|
|
return billing.subscription_get_subscription_vector_space(tenant_id=tenant_id, bypass_cache=bypass_cache)
|
|
|
|
@classmethod
|
|
def invalidate_vector_space_cache(cls, tenant_id: str) -> None:
|
|
cls.get_vector_space(tenant_id, bypass_cache=True)
|
|
|
|
@classmethod
|
|
def get_quota_info(cls, tenant_id: str) -> billing.QuotaGetInfoReply:
|
|
return billing.quota_get_quota_info(tenant_id=tenant_id)
|
|
|
|
@classmethod
|
|
def quota_reserve(
|
|
cls,
|
|
tenant_id: str,
|
|
feature_key: str,
|
|
request_id: str,
|
|
amount: int = 1,
|
|
meta: Mapping[str, object] | None = None,
|
|
bucket: str = "",
|
|
) -> billing.QuotaReserveReply:
|
|
"""Reserve quota before task execution."""
|
|
return billing.quota_reserve(
|
|
body=billing.QuotaReserveReq(
|
|
tenant_id=tenant_id,
|
|
feature_key=feature_key,
|
|
request_id=request_id,
|
|
amount=amount,
|
|
meta=_quota_meta(meta),
|
|
bucket=bucket,
|
|
)
|
|
)
|
|
|
|
@classmethod
|
|
def quota_commit(
|
|
cls,
|
|
tenant_id: str,
|
|
feature_key: str,
|
|
reservation_id: str,
|
|
actual_amount: int,
|
|
meta: Mapping[str, object] | None = None,
|
|
bucket: str = "",
|
|
) -> billing.QuotaCommitReply:
|
|
"""Commit a reservation with actual consumption."""
|
|
return billing.quota_commit(
|
|
body=billing.QuotaCommitReq(
|
|
tenant_id=tenant_id,
|
|
feature_key=feature_key,
|
|
reservation_id=reservation_id,
|
|
actual_amount=actual_amount,
|
|
meta=_quota_meta(meta),
|
|
bucket=bucket,
|
|
)
|
|
)
|
|
|
|
@classmethod
|
|
def quota_release(
|
|
cls, tenant_id: str, feature_key: str, reservation_id: str, bucket: str = ""
|
|
) -> billing.QuotaReleaseReply:
|
|
"""Release a reservation (cancel, return frozen quota)."""
|
|
return billing.quota_release(
|
|
body=billing.QuotaReleaseReq(
|
|
tenant_id=tenant_id, feature_key=feature_key, reservation_id=reservation_id, bucket=bucket
|
|
)
|
|
)
|
|
|
|
@classmethod
|
|
def quota_get_balance(cls, tenant_id: str, feature_key: str, bucket: str = "") -> billing.QuotaGetBalanceReply:
|
|
"""Get quota balance for a feature bucket."""
|
|
return billing.quota_get_balance(tenant_id=tenant_id, feature_key=feature_key, bucket=bucket)
|
|
|
|
@classmethod
|
|
def quota_consume_capped(
|
|
cls,
|
|
tenant_id: str,
|
|
feature_key: str,
|
|
request_id: str,
|
|
amount: int,
|
|
meta: Mapping[str, object] | None = None,
|
|
bucket: str = "",
|
|
) -> billing.QuotaConsumeCappedReply:
|
|
"""Consume up to the available quota and return the actual deducted amount."""
|
|
return billing.quota_consume_capped(
|
|
body=billing.QuotaConsumeCappedReq(
|
|
tenant_id=tenant_id,
|
|
feature_key=feature_key,
|
|
request_id=request_id,
|
|
amount=amount,
|
|
meta=_quota_meta(meta),
|
|
bucket=bucket,
|
|
)
|
|
)
|
|
|
|
@classmethod
|
|
def get_knowledge_rate_limit(cls, tenant_id: str) -> billing.GetKnowledgeRateLimitReply:
|
|
return billing.subscription_get_knowledge_rate_limit(tenant_id=tenant_id)
|
|
|
|
@classmethod
|
|
def get_subscription(
|
|
cls, plan: str, interval: str, prefilled_email: str = "", tenant_id: str = ""
|
|
) -> billing.GetSubscriptionPaymentLinkReply:
|
|
return billing.subscription_get_subscription_payment_link(
|
|
plan=plan, interval=interval, prefilled_email=prefilled_email, tenant_id=tenant_id
|
|
)
|
|
|
|
@classmethod
|
|
@deprecated("Only used by the deprecated model-provider checkout endpoint.")
|
|
def get_model_provider_payment_link(
|
|
cls, provider_name: str, tenant_id: str, account_id: str, prefilled_email: str
|
|
) -> billing.GetModelProviderPaymentLinkReply:
|
|
return billing.model_provider_get_model_provider_payment_link(
|
|
provider_name=provider_name, tenant_id=tenant_id, account_id=account_id, prefilled_email=prefilled_email
|
|
)
|
|
|
|
@classmethod
|
|
def get_invoices(cls, prefilled_email: str = "", tenant_id: str = "") -> billing.GetInvoicesReply:
|
|
return billing.invoice_get_invoices(prefilled_email=prefilled_email, tenant_id=tenant_id)
|
|
|
|
@classmethod
|
|
def delete_account(cls, account_id: str) -> saas.DeleteAccountReply:
|
|
return saas.account_delete_account(account_id=account_id)
|
|
|
|
@classmethod
|
|
def get_email_freeze_type(cls, email: str) -> EmailFreezeType | None:
|
|
try:
|
|
reply = saas.account_check_account_in_freeze(email=email)
|
|
except Exception:
|
|
return None
|
|
if not reply.data:
|
|
return None
|
|
match reply.freeze_type:
|
|
case "freeze" | "email_domain_suspended" as freeze_type:
|
|
return freeze_type
|
|
case _:
|
|
# Billing versions that only return the boolean `data` send an empty freeze type.
|
|
return "freeze"
|
|
|
|
@classmethod
|
|
def is_email_in_freeze(cls, email: str) -> bool:
|
|
return cls.get_email_freeze_type(email) is not None
|
|
|
|
@classmethod
|
|
def update_account_deletion_feedback(cls, email: str, feedback: str) -> saas.AddDeleteFeedbackReply:
|
|
return saas.account_add_delete_feedback(body=saas.AddDeleteFeedbackRequest(email=email, feedback=feedback))
|
|
|
|
class EducationIdentity:
|
|
@classmethod
|
|
def verify(cls, account_id: str) -> billing.VerifyEducationIdentityReply:
|
|
return billing.education_verify_education_identity(account_id=account_id)
|
|
|
|
@classmethod
|
|
def status(cls, account_id: str) -> billing.GetEducationIdentityStatusReply:
|
|
return billing.education_get_education_identity_status(account_id=account_id)
|
|
|
|
@classmethod
|
|
def activate(
|
|
cls,
|
|
*,
|
|
account_id: str,
|
|
tenant_id: str,
|
|
token: str,
|
|
institution: str,
|
|
role: str,
|
|
) -> billing.CreateEducationIdentityReply:
|
|
return billing.education_create_education_identity(
|
|
body=billing.CreateEducationIdentityReq(
|
|
account_id=account_id, curr_tenant_id=tenant_id, institution=institution, role=role, token=token
|
|
)
|
|
)
|
|
|
|
@classmethod
|
|
def autocomplete(
|
|
cls, keywords: str, page: int = 0, limit: int = 20
|
|
) -> billing.AutocompleteEducationInstitutionReply:
|
|
return billing.education_autocomplete_education_institution(keywords=keywords, page=page, limit=limit)
|
|
|
|
@classmethod
|
|
def get_compliance_download_link(
|
|
cls,
|
|
doc_name: str,
|
|
account_id: str,
|
|
tenant_id: str,
|
|
ip: str,
|
|
device_info: str,
|
|
) -> billing.DownloadComplianceDocumentReply:
|
|
return billing.compliance_download_compliance_document(
|
|
body=billing.DownloadComplianceDocumentReq(
|
|
doc_name=doc_name, account_id=account_id, tenant_id=tenant_id, ip_address=ip, device_info=device_info
|
|
)
|
|
)
|
|
|
|
@classmethod
|
|
def clean_billing_info_cache(cls, tenant_id: str) -> None:
|
|
redis_client.delete(f"tenant:{tenant_id}:billing_info")
|
|
|
|
@classmethod
|
|
def get_plan_bulk(cls, tenant_ids: Sequence[str]) -> dict[str, billing.SubscriptionPlanItem]:
|
|
"""
|
|
Bulk fetch billing subscription plan via billing API, at most 200 tenants per request.
|
|
Invalid items are skipped so callers only receive validated plans.
|
|
Returns:
|
|
Mapping of tenant_id -> subscription plan
|
|
"""
|
|
results: dict[str, billing.SubscriptionPlanItem] = {}
|
|
|
|
chunk_size = 200
|
|
for i in range(0, len(tenant_ids), chunk_size):
|
|
chunk = tenant_ids[i : i + chunk_size]
|
|
try:
|
|
reply = billing.subscription_batch_get_subscription_plan(
|
|
body=billing.BatchGetSubscriptionPlanReq(tenant_ids=list(chunk))
|
|
)
|
|
for tenant_id, item in (reply.data or {}).items():
|
|
try:
|
|
# Observed responses can contain constructed models; validating an instance would trust it.
|
|
payload = (
|
|
item.model_dump(warnings=False) if isinstance(item, billing.SubscriptionPlanItem) else item
|
|
)
|
|
results[tenant_id] = billing.SubscriptionPlanItem.model_validate(payload)
|
|
except ValidationError:
|
|
logger.exception(
|
|
"get_plan_bulk: failed to validate subscription plan for tenant(%s)", tenant_id
|
|
)
|
|
except Exception:
|
|
logger.exception("get_plan_bulk: failed to fetch billing info batch for tenants: %s", chunk)
|
|
continue
|
|
|
|
return results
|
|
|
|
@classmethod
|
|
def _make_plan_cache_key(cls, tenant_id: str) -> str:
|
|
return f"{cls._PLAN_CACHE_KEY_PREFIX}{tenant_id}"
|
|
|
|
@classmethod
|
|
def get_plan_bulk_with_cache(cls, tenant_ids: Sequence[str]) -> dict[str, billing.SubscriptionPlanItem]:
|
|
"""
|
|
Bulk fetch billing subscription plan with cache to reduce billing API loads in batch job scenarios.
|
|
|
|
NOTE: if you want to high data consistency, use get_plan_bulk instead.
|
|
|
|
Returns:
|
|
Mapping of tenant_id -> subscription plan
|
|
"""
|
|
tenant_plans: dict[str, billing.SubscriptionPlanItem] = {}
|
|
|
|
if not tenant_ids:
|
|
return tenant_plans
|
|
|
|
# Step 1: Batch fetch from Redis cache using mget
|
|
redis_keys = [cls._make_plan_cache_key(tenant_id) for tenant_id in tenant_ids]
|
|
try:
|
|
cached_values = redis_client.mget(redis_keys)
|
|
|
|
if len(cached_values) != len(tenant_ids):
|
|
raise Exception(
|
|
"get_plan_bulk_with_cache: unexpected error: redis mget failed: cached values length mismatch"
|
|
)
|
|
|
|
# Map cached values back to tenant_ids
|
|
cache_misses: list[str] = []
|
|
|
|
for tenant_id, cached_value in zip(tenant_ids, cached_values, strict=False):
|
|
if cached_value:
|
|
try:
|
|
tenant_plans[tenant_id] = billing.SubscriptionPlanItem.model_validate_json(cached_value)
|
|
except Exception:
|
|
logger.exception(
|
|
"get_plan_bulk_with_cache: process tenant(%s) failed, add to cache misses", tenant_id
|
|
)
|
|
cache_misses.append(tenant_id)
|
|
else:
|
|
cache_misses.append(tenant_id)
|
|
|
|
logger.info(
|
|
"get_plan_bulk_with_cache: cache hits=%s, cache misses=%s",
|
|
len(tenant_plans),
|
|
len(cache_misses),
|
|
)
|
|
except Exception:
|
|
logger.exception("get_plan_bulk_with_cache: redis mget failed, falling back to API")
|
|
cache_misses = list(tenant_ids)
|
|
|
|
# Step 2: Fetch missing plans from billing API
|
|
if cache_misses:
|
|
bulk_plans = BillingService.get_plan_bulk(cache_misses)
|
|
|
|
if bulk_plans:
|
|
plans_to_cache: dict[str, billing.SubscriptionPlanItem] = {}
|
|
|
|
for tenant_id, subscription_plan in bulk_plans.items():
|
|
tenant_plans[tenant_id] = subscription_plan
|
|
plans_to_cache[tenant_id] = subscription_plan
|
|
|
|
# Step 3: Batch update Redis cache using pipeline
|
|
if plans_to_cache:
|
|
try:
|
|
pipe = redis_client.pipeline()
|
|
for tenant_id, subscription_plan in plans_to_cache.items():
|
|
redis_key = cls._make_plan_cache_key(tenant_id)
|
|
pipe.setex(redis_key, cls._PLAN_CACHE_TTL, subscription_plan.model_dump_json())
|
|
pipe.execute()
|
|
|
|
logger.info(
|
|
"get_plan_bulk_with_cache: cached %s new tenant plans to Redis",
|
|
len(plans_to_cache),
|
|
)
|
|
except Exception:
|
|
logger.exception("get_plan_bulk_with_cache: redis pipeline failed")
|
|
|
|
return tenant_plans
|
|
|
|
@classmethod
|
|
def get_expired_subscription_cleanup_whitelist(cls) -> Sequence[str]:
|
|
reply = billing.subscription_list_subscription_cleanup_whitelist()
|
|
return [item.tenant_id for item in reply.data or []]
|
|
|
|
@classmethod
|
|
def get_account_notification(cls, account_id: str) -> saas.GetAccountNotificationReply:
|
|
"""Return the active in-product notifications; fetching marks `once` notifications as seen."""
|
|
return saas.notification_get_account_notification(account_id=account_id)
|
|
|
|
@classmethod
|
|
def dismiss_notification(cls, notification_id: str, account_id: str) -> saas.DismissNotificationReply:
|
|
return saas.notification_dismiss_notification(
|
|
notification_id=notification_id,
|
|
body=saas.DismissNotificationReq(notification_id=notification_id, account_id=account_id),
|
|
)
|