Compare commits

..

4 Commits

Author SHA1 Message Date
ae70d97861 Merge branch 'main' into fix/member-invite-limits 2026-05-25 11:16:03 +08:00
639e12a306 fix: request /api/datasets raise exception (#36591)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-25 02:27:54 +00:00
3f0da07318 fix(web): resolve member invite limit with fallback and null-safe size 2026-05-21 17:07:24 +08:00
6ed98e180d fix: member invite limits with dedup, locking, and accurate new-member counting 2026-05-21 16:51:21 +08:00
16 changed files with 272 additions and 74 deletions

View File

@ -4,6 +4,7 @@ from uuid import UUID
from flask import abort, request
from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter
from sqlalchemy import func, select
import services
from configs import dify_config
@ -22,15 +23,15 @@ from controllers.console.auth.error import (
from controllers.console.error import EmailSendIpLimitError, WorkspaceMembersLimitExceeded
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_resource_check,
is_allow_transfer_owner,
setup_required,
)
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.member_fields import AccountWithRole, AccountWithRoleList
from libs.helper import extract_remote_ip
from libs.login import current_account_with_tenant, login_required
from models.account import Account, TenantAccountRole
from models.account import Account, TenantAccountJoin, TenantAccountRole
from services.account_service import AccountService, RegisterService, TenantService
from services.errors.account import AccountAlreadyInTenantError
from services.feature_service import FeatureService
@ -79,6 +80,54 @@ def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool:
return FeatureService.get_features(tenant_id=tenant_id).dataset_operator_enabled
def _normalize_invitee_emails(emails: list[str]) -> list[str]:
return list(dict.fromkeys(email.lower() for email in emails))
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> int:
new_member_count = 0
for email in emails:
account = AccountService.get_account_by_email_with_case_fallback(email)
if not account:
new_member_count += 1
continue
exists = db.session.scalar(
select(TenantAccountJoin.id)
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.account_id == account.id)
.limit(1)
)
if not exists:
new_member_count += 1
return new_member_count
def _count_current_members(tenant_id: str) -> int:
return (
db.session.scalar(select(func.count(TenantAccountJoin.id)).where(TenantAccountJoin.tenant_id == tenant_id)) or 0
)
def _check_member_invite_limits(tenant_id: str, new_member_count: int) -> None:
if new_member_count <= 0:
return
features = FeatureService.get_features(tenant_id=tenant_id)
if dify_config.ENTERPRISE_ENABLED:
workspace_members = features.workspace_members
if workspace_members.enabled is True and not workspace_members.is_available(new_member_count):
raise WorkspaceMembersLimitExceeded()
return
if dify_config.BILLING_ENABLED and features.billing.enabled is True:
members = features.members
current_member_count = _count_current_members(tenant_id)
if 0 < members.limit < current_member_count + new_member_count:
raise WorkspaceMembersLimitExceeded()
@console_ns.route("/workspaces/current/members")
class MemberListApi(Resource):
"""List all members of current tenant."""
@ -105,12 +154,11 @@ class MemberInviteEmailApi(Resource):
@setup_required
@login_required
@account_initialization_required
@cloud_edition_billing_resource_check("members")
def post(self):
payload = console_ns.payload or {}
args = MemberInvitePayload.model_validate(payload)
invitee_emails = args.emails
invitee_emails = _normalize_invitee_emails(args.emails)
invitee_role = args.role
interface_language = args.language
if not TenantAccountRole.is_non_owner_role(invitee_role):
@ -130,37 +178,36 @@ class MemberInviteEmailApi(Resource):
invitation_results = []
console_web_url = dify_config.CONSOLE_WEB_URL
workspace_members = FeatureService.get_features(tenant_id=inviter.current_tenant.id).workspace_members
tenant_id = inviter.current_tenant.id
with redis_client.lock(f"workspace_member_invite:{tenant_id}", timeout=60):
new_member_count = _count_new_member_invites(tenant_id, invitee_emails)
_check_member_invite_limits(tenant_id, new_member_count)
if not workspace_members.is_available(len(invitee_emails)):
raise WorkspaceMembersLimitExceeded()
for invitee_email in invitee_emails:
normalized_invitee_email = invitee_email.lower()
try:
if not inviter.current_tenant:
raise ValueError("No current tenant")
token = RegisterService.invite_new_member(
tenant=inviter.current_tenant,
email=invitee_email,
language=interface_language,
role=invitee_role,
inviter=inviter,
)
encoded_invitee_email = parse.quote(normalized_invitee_email)
invitation_results.append(
{
"status": "success",
"email": normalized_invitee_email,
"url": f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
}
)
except AccountAlreadyInTenantError:
invitation_results.append(
{"status": "success", "email": normalized_invitee_email, "url": f"{console_web_url}/signin"}
)
except Exception as e:
invitation_results.append({"status": "failed", "email": normalized_invitee_email, "message": str(e)})
for invitee_email in invitee_emails:
try:
if not inviter.current_tenant:
raise ValueError("No current tenant")
token = RegisterService.invite_new_member(
tenant=inviter.current_tenant,
email=invitee_email,
language=interface_language,
role=invitee_role,
inviter=inviter,
)
encoded_invitee_email = parse.quote(invitee_email)
invitation_results.append(
{
"status": "success",
"email": invitee_email,
"url": f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}",
}
)
except AccountAlreadyInTenantError:
invitation_results.append(
{"status": "success", "email": invitee_email, "url": f"{console_web_url}/signin"}
)
except Exception as e:
invitation_results.append({"status": "failed", "email": invitee_email, "message": str(e)})
return {
"result": "success",

View File

@ -162,9 +162,9 @@ class DatasetVectorSettingResponse(ResponseModel):
class DatasetWeightedScoreResponse(ResponseModel):
weight_type: str | None
keyword_setting: DatasetKeywordSettingResponse | None
vector_setting: DatasetVectorSettingResponse | None
weight_type: str | None = None
keyword_setting: DatasetKeywordSettingResponse | None = None
vector_setting: DatasetVectorSettingResponse | None = None
class DatasetRetrievalModelResponse(ResponseModel):

View File

@ -12085,9 +12085,9 @@ Condition detail
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| keyword_setting | [DatasetKeywordSettingResponse](#datasetkeywordsettingresponse) | | Yes |
| vector_setting | [DatasetVectorSettingResponse](#datasetvectorsettingresponse) | | Yes |
| weight_type | string | | Yes |
| keyword_setting | [DatasetKeywordSettingResponse](#datasetkeywordsettingresponse) | | No |
| vector_setting | [DatasetVectorSettingResponse](#datasetvectorsettingresponse) | | No |
| weight_type | string | | No |
#### DatasourceCredentialDeletePayload

View File

@ -2574,9 +2574,9 @@ Condition detail
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| keyword_setting | [DatasetKeywordSettingResponse](#datasetkeywordsettingresponse) | | Yes |
| vector_setting | [DatasetVectorSettingResponse](#datasetvectorsettingresponse) | | Yes |
| weight_type | string | | Yes |
| keyword_setting | [DatasetKeywordSettingResponse](#datasetkeywordsettingresponse) | | No |
| vector_setting | [DatasetVectorSettingResponse](#datasetvectorsettingresponse) | | No |
| weight_type | string | | No |
#### DatasourceNodeRunPayload

View File

@ -245,6 +245,55 @@ class TestDatasetList:
assert status == 200
def test_get_allows_legacy_weighted_score_without_weight_type(self, app: Flask):
api = DatasetListApi()
method = unwrap(api.get)
current_user = self._mock_user()
datasets = [
make_dataset(
retrieval_model={
"search_method": "hybrid_search",
"reranking_enable": True,
"reranking_mode": "weighted_score",
"reranking_model": None,
"weights": {
"vector_setting": {
"vector_weight": 0.7,
"embedding_model_name": "text-embedding",
"embedding_provider_name": "openai",
},
"keyword_setting": {"keyword_weight": 0.3},
},
"top_k": 3,
"score_threshold_enabled": False,
"score_threshold": 0.0,
}
)
]
with app.test_request_context("/datasets"):
with (
patch(
"controllers.console.datasets.datasets.current_account_with_tenant",
return_value=(current_user, "tenant-1"),
),
patch.object(
DatasetService,
"get_datasets",
return_value=(datasets, 1),
),
patch.object(
ProviderManager,
"get_configurations",
return_value=MagicMock(get_models=lambda **_: []),
),
):
resp, status = method(api)
assert status == 200
assert resp["data"][0]["retrieval_model_dict"]["weights"]["weight_type"] is None
def test_embedding_available_false(self, app: Flask):
api = DatasetListApi()
method = unwrap(api.get)

View File

@ -1,3 +1,4 @@
from contextlib import nullcontext
from types import SimpleNamespace
from unittest.mock import patch
@ -18,7 +19,7 @@ def app():
def _build_feature_flags():
placeholder_quota = SimpleNamespace(limit=0, size=0)
workspace_members = SimpleNamespace(is_available=lambda count: True)
workspace_members = SimpleNamespace(enabled=False, is_available=lambda count: True)
return SimpleNamespace(
billing=SimpleNamespace(enabled=False),
workspace_members=workspace_members,
@ -31,6 +32,11 @@ def _build_feature_flags():
class TestMemberInviteEmailApi:
@pytest.fixture(autouse=True)
def _mock_member_invite_lock(self):
with patch("controllers.console.workspace.members.redis_client.lock", return_value=nullcontext()):
yield
@patch("controllers.console.workspace.members.FeatureService.get_features")
@patch("controllers.console.workspace.members.RegisterService.invite_new_member")
@patch("controllers.console.workspace.members.current_account_with_tenant")
@ -52,7 +58,12 @@ class TestMemberInviteEmailApi:
inviter = SimpleNamespace(email="Owner@Example.com", current_tenant=tenant, status="active")
mock_current_account.return_value = (inviter, tenant.id)
with patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "https://console.example.com"):
with (
patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "https://console.example.com"),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=1),
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False),
):
with app.test_request_context(
"/workspaces/current/members/invite-email",
method="POST",
@ -70,7 +81,7 @@ class TestMemberInviteEmailApi:
assert mock_invite_member.call_count == 1
call_args = mock_invite_member.call_args
assert call_args.kwargs["tenant"] == tenant
assert call_args.kwargs["email"] == "User@Example.com"
assert call_args.kwargs["email"] == "user@example.com"
assert call_args.kwargs["language"] == "en-US"
assert call_args.kwargs["role"] == TenantAccountRole.EDITOR
assert call_args.kwargs["inviter"] == inviter

View File

@ -1,3 +1,4 @@
from contextlib import nullcontext
from unittest.mock import MagicMock, patch
import pytest
@ -75,6 +76,11 @@ class TestMemberListApi:
class TestMemberInviteEmailApi:
@pytest.fixture(autouse=True)
def _mock_member_invite_lock(self):
with patch("controllers.console.workspace.members.redis_client.lock", return_value=nullcontext()):
yield
def test_invite_success(self, app: Flask):
api = MemberInviteEmailApi()
method = unwrap(api.post)
@ -82,6 +88,8 @@ class TestMemberInviteEmailApi:
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
features = MagicMock()
features.billing.enabled = False
features.workspace_members.enabled = False
features.workspace_members.is_available.return_value = True
payload = {
@ -94,8 +102,11 @@ class TestMemberInviteEmailApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=1),
patch("controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token"),
patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"),
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False),
):
result, status = method(api)
@ -109,6 +120,8 @@ class TestMemberInviteEmailApi:
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
features = MagicMock()
features.billing.enabled = False
features.workspace_members.enabled = True
features.workspace_members.is_available.return_value = False
payload = {
@ -120,6 +133,38 @@ class TestMemberInviteEmailApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=1),
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", True),
patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False),
):
with pytest.raises(WorkspaceMembersLimitExceeded):
method(api)
def test_invite_billing_limit_exceeded(self, app: Flask):
api = MemberInviteEmailApi()
method = unwrap(api.post)
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
features = MagicMock()
features.billing.enabled = True
features.members.size = 9
features.members.limit = 10
features.workspace_members.enabled = False
payload = {
"emails": ["a@test.com", "b@test.com"],
"role": "normal",
}
with (
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=2),
patch("controllers.console.workspace.members._count_current_members", return_value=9),
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", True),
):
with pytest.raises(WorkspaceMembersLimitExceeded):
method(api)
@ -131,6 +176,8 @@ class TestMemberInviteEmailApi:
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
features = MagicMock()
features.billing.enabled = False
features.workspace_members.enabled = False
features.workspace_members.is_available.return_value = True
payload = {
@ -142,11 +189,14 @@ class TestMemberInviteEmailApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=0),
patch(
"controllers.console.workspace.members.RegisterService.invite_new_member",
side_effect=AccountAlreadyInTenantError(),
),
patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"),
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False),
):
result, status = method(api)
@ -174,6 +224,8 @@ class TestMemberInviteEmailApi:
tenant = MagicMock(id="t1")
user = MagicMock(current_tenant=tenant)
features = MagicMock()
features.billing.enabled = False
features.workspace_members.enabled = False
features.workspace_members.is_available.return_value = True
payload = {
@ -185,11 +237,14 @@ class TestMemberInviteEmailApi:
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.members.current_account_with_tenant", return_value=(user, "t1")),
patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.members._count_new_member_invites", return_value=1),
patch(
"controllers.console.workspace.members.RegisterService.invite_new_member",
side_effect=Exception("boom"),
),
patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"),
patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False),
):
result, _ = method(api)

View File

@ -709,9 +709,9 @@ export type DatasetRerankingModelResponse = {
}
export type DatasetWeightedScoreResponse = {
keyword_setting: DatasetKeywordSettingResponse
vector_setting: DatasetVectorSettingResponse
weight_type: string | null
keyword_setting?: DatasetKeywordSettingResponse
vector_setting?: DatasetVectorSettingResponse
weight_type?: string | null
}
export type DatasetRerankingModel = {

View File

@ -670,9 +670,9 @@ export const zDatasetVectorSettingResponse = z.object({
* DatasetWeightedScoreResponse
*/
export const zDatasetWeightedScoreResponse = z.object({
keyword_setting: zDatasetKeywordSettingResponse,
vector_setting: zDatasetVectorSettingResponse,
weight_type: z.string().nullable(),
keyword_setting: zDatasetKeywordSettingResponse.optional(),
vector_setting: zDatasetVectorSettingResponse.optional(),
weight_type: z.string().nullish(),
})
/**

View File

@ -377,9 +377,9 @@ export type DatasetVectorSettingResponse = {
}
export type DatasetWeightedScoreResponse = {
keyword_setting: DatasetKeywordSettingResponse
vector_setting: DatasetVectorSettingResponse
weight_type: string | null
keyword_setting?: DatasetKeywordSettingResponse
vector_setting?: DatasetVectorSettingResponse
weight_type?: string | null
}
export type DatasourceNodeRunPayload = {

View File

@ -354,9 +354,9 @@ export const zDatasetVectorSettingResponse = z.object({
* DatasetWeightedScoreResponse
*/
export const zDatasetWeightedScoreResponse = z.object({
keyword_setting: zDatasetKeywordSettingResponse,
vector_setting: zDatasetVectorSettingResponse,
weight_type: z.string().nullable(),
keyword_setting: zDatasetKeywordSettingResponse.optional(),
vector_setting: zDatasetVectorSettingResponse.optional(),
weight_type: z.string().nullish(),
})
/**

View File

@ -91,6 +91,7 @@ export type CurrentPlanInfoBackend = {
}
webapp_copyright_enabled: boolean
workspace_members: {
enabled?: boolean
size: number
limit: number
}

View File

@ -28,6 +28,37 @@ type ProviderContextProviderProps = {
children: ReactNode
}
type MemberInviteLimit = {
size: number
limit: number
}
const unlimitedMemberInviteLimit: MemberInviteLimit = {
size: 0,
limit: 0,
}
const resolveMemberInviteLimit = (data: Awaited<ReturnType<typeof fetchCurrentPlanInfo>>): MemberInviteLimit => {
if (!data)
return unlimitedMemberInviteLimit
if (data.workspace_members?.enabled) {
return {
size: data.workspace_members.size,
limit: data.workspace_members.limit,
}
}
if (data.billing?.enabled && data.members?.limit > 0) {
return {
size: data.members.size,
limit: data.members.limit,
}
}
return unlimitedMemberInviteLimit
}
export const ProviderContextProvider = ({
children,
}: ProviderContextProviderProps) => {
@ -87,8 +118,7 @@ export const ProviderContextProvider = ({
setDatasetOperatorEnabled(true)
if (data.webapp_copyright_enabled)
setWebappCopyrightEnabled(true)
if (data.workspace_members)
setLicenseLimit({ workspace_members: data.workspace_members })
setLicenseLimit({ workspace_members: resolveMemberInviteLimit(data) })
if (data.is_allow_transfer_workspace)
setIsAllowTransferWorkspace(data.is_allow_transfer_workspace)
if (data.knowledge_pipeline?.publish_enabled)

View File

@ -21,15 +21,6 @@ describe('Trigger', () => {
expect(screen.getByText('Backend')).toBeInTheDocument()
expect(screen.queryByText('common.tag.addTag')).not.toBeInTheDocument()
})
it('should preserve tag label casing for mixed-case tags', () => {
render(<TagTrigger tags={['Prod', 'prod']} />)
expect(screen.getByText('Prod')).toHaveClass('system-2xs-medium')
expect(screen.getByText('Prod')).not.toHaveClass('system-2xs-medium-uppercase')
expect(screen.getByText('prod')).toHaveClass('system-2xs-medium')
expect(screen.getByText('prod')).not.toHaveClass('system-2xs-medium-uppercase')
})
})
// Prop-driven rendering updates.

View File

@ -34,7 +34,7 @@ export const TagTrigger = ({
className="flex max-w-30 min-w-0 shrink-0 items-center gap-x-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1.25 py-0.75"
>
<span aria-hidden="true" className="i-ri-price-tag-3-line size-3 shrink-0 text-text-quaternary" />
<div className="truncate system-2xs-medium text-text-tertiary">
<div className="truncate system-2xs-medium-uppercase text-text-tertiary">
{content}
</div>
</div>

View File

@ -1,15 +1,29 @@
import type { CurrentPlanInfoBackend, SubscriptionUrlsBackend } from '@/app/components/billing/type'
import { get } from './base'
type CurrentPlanInfoResponse = Omit<CurrentPlanInfoBackend, 'workspace_members'> & {
workspace_members: Omit<CurrentPlanInfoBackend['workspace_members'], 'size'> & {
size: number | null
}
}
export const fetchCurrentPlanInfo = async (): Promise<CurrentPlanInfoBackend> => {
const data = await get<CurrentPlanInfoResponse>('/features')
return {
...data,
workspace_members: {
...data.workspace_members,
size: data.workspace_members.size ?? 0,
},
}
}
export type CurrentPlanVectorSpaceBackend = {
size: number
limit: number
}
export const fetchCurrentPlanInfo = () => {
return get<CurrentPlanInfoBackend>('/features')
}
export const fetchCurrentPlanVectorSpace = () => {
return get<CurrentPlanVectorSpaceBackend>('/features/vector-space')
}