feat: add enterprise identity and channel integrations
This commit is contained in:
381
backend/apps/accounts/identity_api.py
Normal file
381
backend/apps/accounts/identity_api.py
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import login
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import IntegrityError, transaction
|
||||||
|
from django.db.models import Count
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
from apps.accounts.api import require_permission
|
||||||
|
from apps.accounts.identity_schemas import (
|
||||||
|
DirectorySyncIn,
|
||||||
|
DirectorySyncRunOut,
|
||||||
|
EnterpriseGroupMappingIn,
|
||||||
|
EnterpriseGroupMappingOut,
|
||||||
|
IdentityProviderCreateIn,
|
||||||
|
IdentityProviderOut,
|
||||||
|
IdentityProviderUpdateIn,
|
||||||
|
PublicIdentityProviderOut,
|
||||||
|
)
|
||||||
|
from apps.accounts.identity_services import (
|
||||||
|
IdentitySyncError,
|
||||||
|
run_directory_sync,
|
||||||
|
sync_identity_claims,
|
||||||
|
)
|
||||||
|
from apps.accounts.models import (
|
||||||
|
EnterpriseGroupMapping,
|
||||||
|
EnterpriseIdentityProvider,
|
||||||
|
UserGroup,
|
||||||
|
)
|
||||||
|
from apps.accounts.oidc import (
|
||||||
|
OIDCConfigurationError,
|
||||||
|
build_oidc_client,
|
||||||
|
callback_uri,
|
||||||
|
frontend_redirect,
|
||||||
|
normalize_return_to,
|
||||||
|
)
|
||||||
|
from apps.audit.models import OperationAuditLog
|
||||||
|
from apps.modelhub.crypto import (
|
||||||
|
CredentialEncryptionError,
|
||||||
|
encrypt_secret,
|
||||||
|
mask_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = Router(tags=["enterprise-identity"])
|
||||||
|
|
||||||
|
SENSITIVE_CONFIG_FRAGMENTS = ("password", "secret", "token", "credential")
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(user, action: str, target_type: str, target_id: str, snapshot: dict, result="success"):
|
||||||
|
OperationAuditLog.objects.create(
|
||||||
|
user=user if getattr(user, "is_authenticated", False) else None,
|
||||||
|
module="identity",
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
request_snapshot=snapshot,
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_configuration(configuration: Mapping[str, Any]) -> None:
|
||||||
|
def visit(value: Any) -> None:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
for key, nested in value.items():
|
||||||
|
lowered = str(key).lower()
|
||||||
|
if any(fragment in lowered for fragment in SENSITIVE_CONFIG_FRAGMENTS):
|
||||||
|
raise HttpError(400, f"敏感配置 {key} 必须通过 credential 字段加密保存")
|
||||||
|
visit(nested)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for nested in value:
|
||||||
|
visit(nested)
|
||||||
|
|
||||||
|
visit(configuration)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_provider(provider: EnterpriseIdentityProvider) -> None:
|
||||||
|
if provider.provider_type == EnterpriseIdentityProvider.ProviderType.OIDC:
|
||||||
|
if not provider.metadata_url and not provider.issuer:
|
||||||
|
raise HttpError(400, "OIDC 提供方必须配置 metadata_url 或 issuer")
|
||||||
|
if not provider.client_id:
|
||||||
|
raise HttpError(400, "OIDC 提供方必须配置 client_id")
|
||||||
|
if "openid" not in (provider.scopes or []):
|
||||||
|
raise HttpError(400, "OIDC scopes 必须包含 openid")
|
||||||
|
if provider.enabled and not provider.credential_encrypted:
|
||||||
|
raise HttpError(400, "启用 OIDC 前必须配置 client secret")
|
||||||
|
for url in (provider.issuer, provider.metadata_url):
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
parsed = urlparse(url)
|
||||||
|
local_development = settings.DEBUG and parsed.hostname in {"localhost", "127.0.0.1"}
|
||||||
|
if parsed.scheme != "https" and not local_development:
|
||||||
|
raise HttpError(400, "OIDC 地址必须使用 HTTPS")
|
||||||
|
elif provider.provider_type == EnterpriseIdentityProvider.ProviderType.LDAP:
|
||||||
|
if provider.enabled and not provider.configuration.get("server_uri"):
|
||||||
|
raise HttpError(400, "启用 LDAP 前必须配置 server_uri")
|
||||||
|
else:
|
||||||
|
raise HttpError(400, "不支持的身份提供方类型")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_provider(provider: EnterpriseIdentityProvider) -> dict:
|
||||||
|
return {
|
||||||
|
"id": provider.id,
|
||||||
|
"slug": provider.slug,
|
||||||
|
"name": provider.name,
|
||||||
|
"provider_type": provider.provider_type,
|
||||||
|
"enabled": provider.enabled,
|
||||||
|
"button_label": provider.button_label or f"使用 {provider.name} 登录",
|
||||||
|
"issuer": provider.issuer,
|
||||||
|
"metadata_url": provider.metadata_url,
|
||||||
|
"client_id": provider.client_id,
|
||||||
|
"credential_set": bool(provider.credential_encrypted),
|
||||||
|
"credential_masked": mask_secret(provider.credential_encrypted),
|
||||||
|
"scopes": provider.scopes,
|
||||||
|
"claim_mappings": provider.claim_mappings,
|
||||||
|
"configuration": provider.configuration,
|
||||||
|
"auto_create_users": provider.auto_create_users,
|
||||||
|
"sync_profile": provider.sync_profile,
|
||||||
|
"sync_groups": provider.sync_groups,
|
||||||
|
"identity_count": getattr(provider, "identity_count", 0),
|
||||||
|
"group_mapping_count": getattr(provider, "group_mapping_count", 0),
|
||||||
|
"created_at": provider.created_at,
|
||||||
|
"updated_at": provider.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_mapping(mapping: EnterpriseGroupMapping) -> dict:
|
||||||
|
return {
|
||||||
|
"id": mapping.id,
|
||||||
|
"provider_id": mapping.provider_id,
|
||||||
|
"external_group": mapping.external_group,
|
||||||
|
"user_group_id": mapping.user_group_id,
|
||||||
|
"user_group_name": mapping.user_group.name,
|
||||||
|
"user_group_code": mapping.user_group.code,
|
||||||
|
"created_at": mapping.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/providers", response=list[PublicIdentityProviderOut], auth=None)
|
||||||
|
def public_providers(request):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"slug": provider.slug,
|
||||||
|
"name": provider.name,
|
||||||
|
"button_label": provider.button_label or f"使用 {provider.name} 登录",
|
||||||
|
"provider_type": provider.provider_type,
|
||||||
|
}
|
||||||
|
for provider in EnterpriseIdentityProvider.objects.filter(
|
||||||
|
enabled=True,
|
||||||
|
provider_type=EnterpriseIdentityProvider.ProviderType.OIDC,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/oidc/{slug}/start", auth=None)
|
||||||
|
def oidc_start(request, slug: str, return_to: str = "/"):
|
||||||
|
provider = EnterpriseIdentityProvider.objects.filter(
|
||||||
|
slug=slug,
|
||||||
|
enabled=True,
|
||||||
|
provider_type=EnterpriseIdentityProvider.ProviderType.OIDC,
|
||||||
|
).first()
|
||||||
|
if provider is None:
|
||||||
|
raise HttpError(404, "身份提供方不存在或未启用")
|
||||||
|
try:
|
||||||
|
client = build_oidc_client(provider)
|
||||||
|
request.session[f"identity_return_to:{provider.slug}"] = normalize_return_to(return_to)
|
||||||
|
return client.authorize_redirect(request, callback_uri(request, provider))
|
||||||
|
except (OIDCConfigurationError, CredentialEncryptionError) as error:
|
||||||
|
_audit(None, "oidc_start_failed", "identity_provider", str(provider.id), {}, "failed")
|
||||||
|
raise HttpError(503, str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/oidc/{slug}/callback", auth=None)
|
||||||
|
def oidc_callback(request, slug: str):
|
||||||
|
provider = EnterpriseIdentityProvider.objects.filter(
|
||||||
|
slug=slug,
|
||||||
|
enabled=True,
|
||||||
|
provider_type=EnterpriseIdentityProvider.ProviderType.OIDC,
|
||||||
|
).first()
|
||||||
|
if provider is None:
|
||||||
|
return HttpResponseRedirect(frontend_redirect(error="身份提供方不存在或未启用"))
|
||||||
|
try:
|
||||||
|
client = build_oidc_client(provider)
|
||||||
|
token = client.authorize_access_token(request)
|
||||||
|
claims = token.get("userinfo")
|
||||||
|
if not isinstance(claims, Mapping):
|
||||||
|
raise IdentitySyncError("OIDC 响应缺少已校验的用户身份声明")
|
||||||
|
identity, created = sync_identity_claims(provider, claims, login_event=True)
|
||||||
|
login(request, identity.user)
|
||||||
|
return_to = request.session.pop(f"identity_return_to:{provider.slug}", "/")
|
||||||
|
_audit(
|
||||||
|
identity.user,
|
||||||
|
"oidc_login",
|
||||||
|
"external_identity",
|
||||||
|
str(identity.id),
|
||||||
|
{"provider": provider.slug, "identity_created": created},
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(frontend_redirect(return_to))
|
||||||
|
except Exception as error:
|
||||||
|
_audit(
|
||||||
|
None,
|
||||||
|
"oidc_callback_failed",
|
||||||
|
"identity_provider",
|
||||||
|
str(provider.id),
|
||||||
|
{"error_type": type(error).__name__},
|
||||||
|
"failed",
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(frontend_redirect(error="企业登录失败,请联系管理员"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers", response=list[IdentityProviderOut])
|
||||||
|
def list_providers(request):
|
||||||
|
require_permission(request, "system.setting.manage")
|
||||||
|
queryset = EnterpriseIdentityProvider.objects.annotate(
|
||||||
|
identity_count=Count("external_identities", distinct=True),
|
||||||
|
group_mapping_count=Count("group_mappings", distinct=True),
|
||||||
|
)
|
||||||
|
return [_serialize_provider(provider) for provider in queryset]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/providers", response=IdentityProviderOut)
|
||||||
|
def create_provider(request, payload: IdentityProviderCreateIn):
|
||||||
|
require_permission(request, "system.setting.manage")
|
||||||
|
values = payload.dict()
|
||||||
|
credential = values.pop("credential", "")
|
||||||
|
_validate_configuration(values["configuration"])
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
provider = EnterpriseIdentityProvider(
|
||||||
|
**values,
|
||||||
|
credential_encrypted=encrypt_secret(credential) if credential else "",
|
||||||
|
created_by=request.user,
|
||||||
|
updated_by=request.user,
|
||||||
|
)
|
||||||
|
_validate_provider(provider)
|
||||||
|
provider.full_clean()
|
||||||
|
provider.save()
|
||||||
|
except CredentialEncryptionError as error:
|
||||||
|
raise HttpError(400, str(error)) from error
|
||||||
|
except ValidationError as error:
|
||||||
|
raise HttpError(400, "; ".join(error.messages)) from error
|
||||||
|
except IntegrityError as error:
|
||||||
|
raise HttpError(400, "身份提供方标识已存在") from error
|
||||||
|
_audit(
|
||||||
|
request.user,
|
||||||
|
"create_provider",
|
||||||
|
"identity_provider",
|
||||||
|
str(provider.id),
|
||||||
|
{"slug": provider.slug, "provider_type": provider.provider_type},
|
||||||
|
)
|
||||||
|
provider.identity_count = 0
|
||||||
|
provider.group_mapping_count = 0
|
||||||
|
return _serialize_provider(provider)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/providers/{provider_id}", response=IdentityProviderOut)
|
||||||
|
def update_provider(request, provider_id: int, payload: IdentityProviderUpdateIn):
|
||||||
|
require_permission(request, "system.setting.manage")
|
||||||
|
provider = EnterpriseIdentityProvider.objects.get(id=provider_id)
|
||||||
|
values = payload.dict(exclude_unset=True)
|
||||||
|
credential = values.pop("credential", None)
|
||||||
|
if "configuration" in values:
|
||||||
|
_validate_configuration(values["configuration"])
|
||||||
|
try:
|
||||||
|
if credential:
|
||||||
|
provider.credential_encrypted = encrypt_secret(credential)
|
||||||
|
for field, value in values.items():
|
||||||
|
setattr(provider, field, value)
|
||||||
|
provider.updated_by = request.user
|
||||||
|
_validate_provider(provider)
|
||||||
|
provider.full_clean()
|
||||||
|
provider.save()
|
||||||
|
except CredentialEncryptionError as error:
|
||||||
|
raise HttpError(400, str(error)) from error
|
||||||
|
except ValidationError as error:
|
||||||
|
raise HttpError(400, "; ".join(error.messages)) from error
|
||||||
|
_audit(
|
||||||
|
request.user,
|
||||||
|
"update_provider",
|
||||||
|
"identity_provider",
|
||||||
|
str(provider.id),
|
||||||
|
{"fields": sorted(values), "credential_replaced": bool(credential)},
|
||||||
|
)
|
||||||
|
provider.identity_count = provider.external_identities.count()
|
||||||
|
provider.group_mapping_count = provider.group_mappings.count()
|
||||||
|
return _serialize_provider(provider)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/providers/{provider_id}/group-mappings",
|
||||||
|
response=list[EnterpriseGroupMappingOut],
|
||||||
|
)
|
||||||
|
def list_group_mappings(request, provider_id: int):
|
||||||
|
require_permission(request, "system.setting.manage")
|
||||||
|
mappings = EnterpriseGroupMapping.objects.select_related("user_group").filter(
|
||||||
|
provider_id=provider_id
|
||||||
|
)
|
||||||
|
return [_serialize_mapping(mapping) for mapping in mappings]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/providers/{provider_id}/group-mappings",
|
||||||
|
response=EnterpriseGroupMappingOut,
|
||||||
|
)
|
||||||
|
def create_group_mapping(
|
||||||
|
request,
|
||||||
|
provider_id: int,
|
||||||
|
payload: EnterpriseGroupMappingIn,
|
||||||
|
):
|
||||||
|
require_permission(request, "system.setting.manage")
|
||||||
|
provider = EnterpriseIdentityProvider.objects.get(id=provider_id)
|
||||||
|
user_group = UserGroup.objects.get(id=payload.user_group_id)
|
||||||
|
external_group = payload.external_group.strip()
|
||||||
|
if not external_group:
|
||||||
|
raise HttpError(400, "企业组织标识不能为空")
|
||||||
|
try:
|
||||||
|
mapping = EnterpriseGroupMapping.objects.create(
|
||||||
|
provider=provider,
|
||||||
|
external_group=external_group,
|
||||||
|
user_group=user_group,
|
||||||
|
)
|
||||||
|
except IntegrityError as error:
|
||||||
|
raise HttpError(400, "该外部组织映射已存在") from error
|
||||||
|
_audit(
|
||||||
|
request.user,
|
||||||
|
"create_group_mapping",
|
||||||
|
"identity_provider",
|
||||||
|
str(provider.id),
|
||||||
|
{"external_group": mapping.external_group, "user_group_id": user_group.id},
|
||||||
|
)
|
||||||
|
return _serialize_mapping(mapping)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/group-mappings/{mapping_id}")
|
||||||
|
def delete_group_mapping(request, mapping_id: int):
|
||||||
|
require_permission(request, "system.setting.manage")
|
||||||
|
mapping = EnterpriseGroupMapping.objects.select_related("provider").get(id=mapping_id)
|
||||||
|
snapshot = {
|
||||||
|
"external_group": mapping.external_group,
|
||||||
|
"user_group_id": mapping.user_group_id,
|
||||||
|
}
|
||||||
|
provider_id = mapping.provider_id
|
||||||
|
mapping.delete()
|
||||||
|
_audit(
|
||||||
|
request.user,
|
||||||
|
"delete_group_mapping",
|
||||||
|
"identity_provider",
|
||||||
|
str(provider_id),
|
||||||
|
snapshot,
|
||||||
|
)
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/providers/{provider_id}/directory-sync",
|
||||||
|
response=DirectorySyncRunOut,
|
||||||
|
)
|
||||||
|
def directory_sync(request, provider_id: int, payload: DirectorySyncIn):
|
||||||
|
require_permission(request, "system.setting.manage")
|
||||||
|
if len(payload.records) > 1000:
|
||||||
|
raise HttpError(400, "单次目录同步最多接收 1000 条记录")
|
||||||
|
provider = EnterpriseIdentityProvider.objects.get(id=provider_id)
|
||||||
|
records = [record.dict() for record in payload.records]
|
||||||
|
run = run_directory_sync(provider, records, request.user)
|
||||||
|
_audit(
|
||||||
|
request.user,
|
||||||
|
"directory_sync",
|
||||||
|
"identity_provider",
|
||||||
|
str(provider.id),
|
||||||
|
{
|
||||||
|
"run_id": run.id,
|
||||||
|
"total_count": run.total_count,
|
||||||
|
"failed_count": run.failed_count,
|
||||||
|
},
|
||||||
|
"success" if run.failed_count == 0 else "failed",
|
||||||
|
)
|
||||||
|
return run
|
||||||
110
backend/apps/accounts/identity_schemas.py
Normal file
110
backend/apps/accounts/identity_schemas.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class PublicIdentityProviderOut(Schema):
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
button_label: str
|
||||||
|
provider_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityProviderOut(Schema):
|
||||||
|
id: int
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
provider_type: str
|
||||||
|
enabled: bool
|
||||||
|
button_label: str
|
||||||
|
issuer: str
|
||||||
|
metadata_url: str
|
||||||
|
client_id: str
|
||||||
|
credential_set: bool
|
||||||
|
credential_masked: str
|
||||||
|
scopes: list[str]
|
||||||
|
claim_mappings: dict[str, str]
|
||||||
|
configuration: dict[str, Any]
|
||||||
|
auto_create_users: bool
|
||||||
|
sync_profile: bool
|
||||||
|
sync_groups: bool
|
||||||
|
identity_count: int
|
||||||
|
group_mapping_count: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityProviderCreateIn(Schema):
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
provider_type: str = "oidc"
|
||||||
|
enabled: bool = False
|
||||||
|
button_label: str = ""
|
||||||
|
issuer: str = ""
|
||||||
|
metadata_url: str = ""
|
||||||
|
client_id: str = ""
|
||||||
|
credential: str = ""
|
||||||
|
scopes: list[str] = ["openid", "profile", "email"]
|
||||||
|
claim_mappings: dict[str, str] = {}
|
||||||
|
configuration: dict[str, Any] = {}
|
||||||
|
auto_create_users: bool = True
|
||||||
|
sync_profile: bool = True
|
||||||
|
sync_groups: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityProviderUpdateIn(Schema):
|
||||||
|
name: str | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
button_label: str | None = None
|
||||||
|
issuer: str | None = None
|
||||||
|
metadata_url: str | None = None
|
||||||
|
client_id: str | None = None
|
||||||
|
credential: str | None = None
|
||||||
|
scopes: list[str] | None = None
|
||||||
|
claim_mappings: dict[str, str] | None = None
|
||||||
|
configuration: dict[str, Any] | None = None
|
||||||
|
auto_create_users: bool | None = None
|
||||||
|
sync_profile: bool | None = None
|
||||||
|
sync_groups: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EnterpriseGroupMappingOut(Schema):
|
||||||
|
id: int
|
||||||
|
provider_id: int
|
||||||
|
external_group: str
|
||||||
|
user_group_id: int
|
||||||
|
user_group_name: str
|
||||||
|
user_group_code: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class EnterpriseGroupMappingIn(Schema):
|
||||||
|
external_group: str
|
||||||
|
user_group_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryRecordIn(Schema):
|
||||||
|
subject: str
|
||||||
|
username: str = ""
|
||||||
|
email: str = ""
|
||||||
|
display_name: str = ""
|
||||||
|
active: bool = True
|
||||||
|
groups: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class DirectorySyncIn(Schema):
|
||||||
|
records: list[DirectoryRecordIn]
|
||||||
|
|
||||||
|
|
||||||
|
class DirectorySyncRunOut(Schema):
|
||||||
|
id: int
|
||||||
|
provider_id: int
|
||||||
|
status: str
|
||||||
|
total_count: int
|
||||||
|
created_count: int
|
||||||
|
updated_count: int
|
||||||
|
failed_count: int
|
||||||
|
errors: list[dict[str, Any]]
|
||||||
|
started_at: datetime
|
||||||
|
finished_at: datetime | None = None
|
||||||
230
backend/apps/accounts/identity_services.py
Normal file
230
backend/apps/accounts/identity_services.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""企业身份声明、目录账号和组织映射同步服务。"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import IntegrityError, transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.accounts.models import (
|
||||||
|
EnterpriseDirectorySyncRun,
|
||||||
|
EnterpriseIdentityProvider,
|
||||||
|
ExternalIdentity,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_CLAIM_MAPPINGS = {
|
||||||
|
"subject": "sub",
|
||||||
|
"username": "preferred_username",
|
||||||
|
"email": "email",
|
||||||
|
"display_name": "name",
|
||||||
|
"groups": "groups",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class IdentitySyncError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_claim_mappings(provider: EnterpriseIdentityProvider) -> dict[str, str]:
|
||||||
|
return {**DEFAULT_CLAIM_MAPPINGS, **(provider.claim_mappings or {})}
|
||||||
|
|
||||||
|
|
||||||
|
def read_claim(claims: Mapping[str, Any], path: str, default: Any = "") -> Any:
|
||||||
|
value: Any = claims
|
||||||
|
for part in path.split("."):
|
||||||
|
if not isinstance(value, Mapping) or part not in value:
|
||||||
|
return default
|
||||||
|
value = value[part]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_username(value: str) -> str:
|
||||||
|
cleaned = re.sub(r"[^@\w.+-]", "_", value.strip(), flags=re.UNICODE).strip("._")
|
||||||
|
return cleaned[:150]
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_username(provider: EnterpriseIdentityProvider, preferred: str, subject: str) -> str:
|
||||||
|
user_model = get_user_model()
|
||||||
|
base = (
|
||||||
|
_clean_username(preferred)
|
||||||
|
or _clean_username(f"{provider.slug}_{subject}")
|
||||||
|
or provider.slug
|
||||||
|
)
|
||||||
|
if not user_model.objects.filter(username=base).exists():
|
||||||
|
return base
|
||||||
|
for suffix in range(2, 10000):
|
||||||
|
candidate = f"{base[: 150 - len(str(suffix)) - 1]}-{suffix}"
|
||||||
|
if not user_model.objects.filter(username=candidate).exists():
|
||||||
|
return candidate
|
||||||
|
raise IdentitySyncError("无法生成唯一的本地账号")
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_list(value: Any) -> list[str]:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return [str(item).strip() for item in value if str(item).strip()]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_business_groups(
|
||||||
|
provider: EnterpriseIdentityProvider,
|
||||||
|
identity: ExternalIdentity,
|
||||||
|
external_groups: list[str],
|
||||||
|
) -> None:
|
||||||
|
mappings = list(provider.group_mappings.select_related("user_group"))
|
||||||
|
managed_group_ids = {mapping.user_group_id for mapping in mappings}
|
||||||
|
desired_group_ids = {
|
||||||
|
mapping.user_group_id
|
||||||
|
for mapping in mappings
|
||||||
|
if mapping.external_group in set(external_groups) and mapping.user_group.is_active
|
||||||
|
}
|
||||||
|
if managed_group_ids:
|
||||||
|
identity.user.business_groups.remove(*(managed_group_ids - desired_group_ids))
|
||||||
|
if desired_group_ids:
|
||||||
|
identity.user.business_groups.add(*desired_group_ids)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def sync_identity_claims(
|
||||||
|
provider: EnterpriseIdentityProvider,
|
||||||
|
claims: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
login_event: bool = False,
|
||||||
|
active: bool | None = None,
|
||||||
|
claim_mappings: Mapping[str, str] | None = None,
|
||||||
|
) -> tuple[ExternalIdentity, bool]:
|
||||||
|
"""按稳定 subject 同步本地账号、档案和受管业务组,不依据显示名关联身份。"""
|
||||||
|
|
||||||
|
mappings = dict(claim_mappings or resolved_claim_mappings(provider))
|
||||||
|
subject = str(read_claim(claims, mappings["subject"], "")).strip()
|
||||||
|
if not subject:
|
||||||
|
raise IdentitySyncError("身份声明缺少稳定 subject")
|
||||||
|
|
||||||
|
identity = (
|
||||||
|
ExternalIdentity.objects.select_for_update()
|
||||||
|
.select_related("user")
|
||||||
|
.filter(provider=provider, subject=subject)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
created = identity is None
|
||||||
|
email = str(read_claim(claims, mappings["email"], "") or "").strip().lower()
|
||||||
|
preferred_username = str(read_claim(claims, mappings["username"], "") or "").strip()
|
||||||
|
display_name = str(read_claim(claims, mappings["display_name"], "") or "").strip()
|
||||||
|
|
||||||
|
if identity is None:
|
||||||
|
if not provider.auto_create_users:
|
||||||
|
raise IdentitySyncError("该身份提供方不允许自动创建账号")
|
||||||
|
user_model = get_user_model()
|
||||||
|
user = None
|
||||||
|
if email and provider.configuration.get("allow_email_linking") is True:
|
||||||
|
user = user_model.objects.filter(email__iexact=email).order_by("id").first()
|
||||||
|
if user is None:
|
||||||
|
username = _unique_username(provider, preferred_username or email, subject)
|
||||||
|
user = user_model(username=username, email=email, first_name=display_name)
|
||||||
|
user.set_unusable_password()
|
||||||
|
user.save()
|
||||||
|
identity = ExternalIdentity.objects.create(
|
||||||
|
provider=provider,
|
||||||
|
subject=subject,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
user = identity.user
|
||||||
|
changed_fields: list[str] = []
|
||||||
|
if provider.sync_profile:
|
||||||
|
if email and user.email != email:
|
||||||
|
user.email = email
|
||||||
|
changed_fields.append("email")
|
||||||
|
if display_name and user.first_name != display_name:
|
||||||
|
user.first_name = display_name
|
||||||
|
changed_fields.append("first_name")
|
||||||
|
if active is not None and user.is_active != active:
|
||||||
|
user.is_active = active
|
||||||
|
changed_fields.append("is_active")
|
||||||
|
if changed_fields:
|
||||||
|
user.save(update_fields=changed_fields)
|
||||||
|
if login_event and not user.is_active:
|
||||||
|
raise IdentitySyncError("账号已停用")
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
identity.claims_snapshot = dict(claims)
|
||||||
|
identity.last_synced_at = now
|
||||||
|
update_fields = ["claims_snapshot", "last_synced_at", "updated_at"]
|
||||||
|
if login_event:
|
||||||
|
identity.last_login_at = now
|
||||||
|
update_fields.append("last_login_at")
|
||||||
|
identity.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
if provider.sync_groups:
|
||||||
|
_sync_business_groups(
|
||||||
|
provider,
|
||||||
|
identity,
|
||||||
|
_claim_list(read_claim(claims, mappings["groups"], [])),
|
||||||
|
)
|
||||||
|
return identity, created
|
||||||
|
|
||||||
|
|
||||||
|
def run_directory_sync(
|
||||||
|
provider: EnterpriseIdentityProvider,
|
||||||
|
records: list[dict[str, Any]],
|
||||||
|
initiated_by,
|
||||||
|
) -> EnterpriseDirectorySyncRun:
|
||||||
|
"""逐条同步目录快照,隔离单条失败并保留最多一百条脱敏错误摘要。"""
|
||||||
|
|
||||||
|
run = EnterpriseDirectorySyncRun.objects.create(
|
||||||
|
provider=provider,
|
||||||
|
initiated_by=initiated_by,
|
||||||
|
total_count=len(records),
|
||||||
|
)
|
||||||
|
errors: list[dict[str, str]] = []
|
||||||
|
for index, record in enumerate(records):
|
||||||
|
claims = {
|
||||||
|
"sub": record.get("subject", ""),
|
||||||
|
"preferred_username": record.get("username", ""),
|
||||||
|
"email": record.get("email", ""),
|
||||||
|
"name": record.get("display_name", ""),
|
||||||
|
"groups": record.get("groups", []),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
_, created = sync_identity_claims(
|
||||||
|
provider,
|
||||||
|
claims,
|
||||||
|
active=bool(record.get("active", True)),
|
||||||
|
claim_mappings=DEFAULT_CLAIM_MAPPINGS,
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
run.created_count += 1
|
||||||
|
else:
|
||||||
|
run.updated_count += 1
|
||||||
|
except (IdentitySyncError, IntegrityError) as error:
|
||||||
|
run.failed_count += 1
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"index": str(index),
|
||||||
|
"subject": str(record.get("subject", ""))[:255],
|
||||||
|
"error": str(error),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
run.errors = errors[:100]
|
||||||
|
if run.failed_count == 0:
|
||||||
|
run.status = EnterpriseDirectorySyncRun.Status.COMPLETED
|
||||||
|
elif run.failed_count == run.total_count:
|
||||||
|
run.status = EnterpriseDirectorySyncRun.Status.FAILED
|
||||||
|
else:
|
||||||
|
run.status = EnterpriseDirectorySyncRun.Status.PARTIAL
|
||||||
|
run.finished_at = timezone.now()
|
||||||
|
run.save(
|
||||||
|
update_fields=[
|
||||||
|
"created_count",
|
||||||
|
"updated_count",
|
||||||
|
"failed_count",
|
||||||
|
"errors",
|
||||||
|
"status",
|
||||||
|
"finished_at",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return run
|
||||||
@@ -102,6 +102,24 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = (
|
|||||||
40,
|
40,
|
||||||
"agent.run.view",
|
"agent.run.view",
|
||||||
),
|
),
|
||||||
|
MenuSpec(
|
||||||
|
"platform.tools",
|
||||||
|
"Tool / MCP",
|
||||||
|
"#/tools",
|
||||||
|
"settings",
|
||||||
|
"platform",
|
||||||
|
50,
|
||||||
|
"tool.view",
|
||||||
|
),
|
||||||
|
MenuSpec(
|
||||||
|
"platform.channels",
|
||||||
|
"渠道接入",
|
||||||
|
"#/channels",
|
||||||
|
"message",
|
||||||
|
"platform",
|
||||||
|
60,
|
||||||
|
"channel.view",
|
||||||
|
),
|
||||||
MenuSpec("system", "系统治理", icon="shield", sort_order=50),
|
MenuSpec("system", "系统治理", icon="shield", sort_order=50),
|
||||||
MenuSpec(
|
MenuSpec(
|
||||||
"system.users", "用户管理", "#/system/users", "users", "system", 10, "system.user.view"
|
"system.users", "用户管理", "#/system/users", "users", "system", 10, "system.user.view"
|
||||||
@@ -121,6 +139,15 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = (
|
|||||||
MenuSpec(
|
MenuSpec(
|
||||||
"system.menus", "菜单权限", "#/system/menus", "menu", "system", 40, "system.menu.view"
|
"system.menus", "菜单权限", "#/system/menus", "menu", "system", 40, "system.menu.view"
|
||||||
),
|
),
|
||||||
MenuSpec("system.audit", "操作审计", "#/audit", "audit", "system", 50, "system.audit.view"),
|
MenuSpec(
|
||||||
MenuSpec("system.design", "设计系统", "#/design-system", "settings", "system", 60),
|
"system.identity",
|
||||||
|
"企业身份",
|
||||||
|
"#/system/identity",
|
||||||
|
"shield",
|
||||||
|
"system",
|
||||||
|
50,
|
||||||
|
"system.setting.manage",
|
||||||
|
),
|
||||||
|
MenuSpec("system.audit", "操作审计", "#/audit", "audit", "system", 60, "system.audit.view"),
|
||||||
|
MenuSpec("system.design", "设计系统", "#/design-system", "settings", "system", 70),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Generated by Django 5.2.15 on 2026-07-27 08:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0002_reorganize_navigation_menus'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EnterpriseIdentityProvider',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('slug', models.SlugField(max_length=80, unique=True)),
|
||||||
|
('name', models.CharField(max_length=120)),
|
||||||
|
('provider_type', models.CharField(choices=[('oidc', 'OpenID Connect'), ('ldap', 'LDAP')], default='oidc', max_length=20)),
|
||||||
|
('enabled', models.BooleanField(default=False)),
|
||||||
|
('button_label', models.CharField(blank=True, max_length=120)),
|
||||||
|
('issuer', models.URLField(blank=True, max_length=500)),
|
||||||
|
('metadata_url', models.URLField(blank=True, max_length=500)),
|
||||||
|
('client_id', models.CharField(blank=True, max_length=255)),
|
||||||
|
('credential_encrypted', models.TextField(blank=True)),
|
||||||
|
('scopes', models.JSONField(blank=True, default=list)),
|
||||||
|
('claim_mappings', models.JSONField(blank=True, default=dict)),
|
||||||
|
('configuration', models.JSONField(blank=True, default=dict)),
|
||||||
|
('auto_create_users', models.BooleanField(default=True)),
|
||||||
|
('sync_profile', models.BooleanField(default=True)),
|
||||||
|
('sync_groups', models.BooleanField(default=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_identity_providers', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_identity_providers', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name', 'id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EnterpriseGroupMapping',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('external_group', models.CharField(max_length=255)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('user_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enterprise_identity_mappings', to='accounts.usergroup')),
|
||||||
|
('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_mappings', to='accounts.enterpriseidentityprovider')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['provider_id', 'external_group'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EnterpriseDirectorySyncRun',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(choices=[('running', '运行中'), ('completed', '已完成'), ('partial', '部分完成'), ('failed', '失败')], default='running', max_length=20)),
|
||||||
|
('total_count', models.PositiveIntegerField(default=0)),
|
||||||
|
('created_count', models.PositiveIntegerField(default=0)),
|
||||||
|
('updated_count', models.PositiveIntegerField(default=0)),
|
||||||
|
('failed_count', models.PositiveIntegerField(default=0)),
|
||||||
|
('errors', models.JSONField(blank=True, default=list)),
|
||||||
|
('started_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('finished_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('initiated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='enterprise_directory_sync_runs', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='directory_sync_runs', to='accounts.enterpriseidentityprovider')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-started_at', '-id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ExternalIdentity',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('subject', models.CharField(max_length=255)),
|
||||||
|
('claims_snapshot', models.JSONField(blank=True, default=dict)),
|
||||||
|
('last_login_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('last_synced_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_identities', to='accounts.enterpriseidentityprovider')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_identities', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['provider_id', 'subject'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='enterpriseidentityprovider',
|
||||||
|
index=models.Index(fields=['provider_type', 'enabled'], name='acct_idp_type_enabled_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='enterprisegroupmapping',
|
||||||
|
constraint=models.UniqueConstraint(fields=('provider', 'external_group'), name='acct_idp_external_group_uniq'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='enterprisedirectorysyncrun',
|
||||||
|
index=models.Index(fields=['provider', 'started_at'], name='acct_dirsync_idp_started_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='externalidentity',
|
||||||
|
index=models.Index(fields=['user', 'provider'], name='acct_identity_user_idp_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='externalidentity',
|
||||||
|
constraint=models.UniqueConstraint(fields=('provider', 'subject'), name='acct_identity_provider_subject_uniq'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='externalidentity',
|
||||||
|
constraint=models.UniqueConstraint(fields=('provider', 'user'), name='acct_identity_provider_user_uniq'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def add_identity_menu(apps, schema_editor):
|
||||||
|
menu_item = apps.get_model("accounts", "MenuItem")
|
||||||
|
parent = menu_item.objects.filter(code="system").first()
|
||||||
|
menu_item.objects.update_or_create(
|
||||||
|
code="system.identity",
|
||||||
|
defaults={
|
||||||
|
"title": "企业身份",
|
||||||
|
"path": "#/system/identity",
|
||||||
|
"icon": "shield",
|
||||||
|
"parent": parent,
|
||||||
|
"sort_order": 50,
|
||||||
|
"permission_code": "system.setting.manage",
|
||||||
|
"is_visible": True,
|
||||||
|
"is_enabled": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
menu_item.objects.filter(code="system.audit").update(sort_order=60)
|
||||||
|
menu_item.objects.filter(code="system.design").update(sort_order=70)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_identity_menu(apps, schema_editor):
|
||||||
|
menu_item = apps.get_model("accounts", "MenuItem")
|
||||||
|
menu_item.objects.filter(code="system.identity").delete()
|
||||||
|
menu_item.objects.filter(code="system.audit").update(sort_order=50)
|
||||||
|
menu_item.objects.filter(code="system.design").update(sort_order=60)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("accounts", "0003_enterpriseidentityprovider_enterprisegroupmapping_and_more")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(add_identity_menu, remove_identity_menu),
|
||||||
|
]
|
||||||
@@ -44,3 +44,148 @@ class MenuItem(models.Model):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class EnterpriseIdentityProvider(models.Model):
|
||||||
|
class ProviderType(models.TextChoices):
|
||||||
|
OIDC = "oidc", "OpenID Connect"
|
||||||
|
LDAP = "ldap", "LDAP"
|
||||||
|
|
||||||
|
slug = models.SlugField(max_length=80, unique=True)
|
||||||
|
name = models.CharField(max_length=120)
|
||||||
|
provider_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=ProviderType.choices,
|
||||||
|
default=ProviderType.OIDC,
|
||||||
|
)
|
||||||
|
enabled = models.BooleanField(default=False)
|
||||||
|
button_label = models.CharField(max_length=120, blank=True)
|
||||||
|
issuer = models.URLField(max_length=500, blank=True)
|
||||||
|
metadata_url = models.URLField(max_length=500, blank=True)
|
||||||
|
client_id = models.CharField(max_length=255, blank=True)
|
||||||
|
credential_encrypted = models.TextField(blank=True)
|
||||||
|
scopes = models.JSONField(default=list, blank=True)
|
||||||
|
claim_mappings = models.JSONField(default=dict, blank=True)
|
||||||
|
configuration = models.JSONField(default=dict, blank=True)
|
||||||
|
auto_create_users = models.BooleanField(default=True)
|
||||||
|
sync_profile = models.BooleanField(default=True)
|
||||||
|
sync_groups = models.BooleanField(default=True)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="created_identity_providers",
|
||||||
|
)
|
||||||
|
updated_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="updated_identity_providers",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name", "id"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["provider_type", "enabled"], name="acct_idp_type_enabled_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalIdentity(models.Model):
|
||||||
|
provider = models.ForeignKey(
|
||||||
|
EnterpriseIdentityProvider,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="external_identities",
|
||||||
|
)
|
||||||
|
subject = models.CharField(max_length=255)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="external_identities",
|
||||||
|
)
|
||||||
|
claims_snapshot = models.JSONField(default=dict, blank=True)
|
||||||
|
last_login_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
last_synced_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["provider_id", "subject"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["provider", "subject"],
|
||||||
|
name="acct_identity_provider_subject_uniq",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["provider", "user"],
|
||||||
|
name="acct_identity_provider_user_uniq",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "provider"], name="acct_identity_user_idp_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EnterpriseGroupMapping(models.Model):
|
||||||
|
provider = models.ForeignKey(
|
||||||
|
EnterpriseIdentityProvider,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="group_mappings",
|
||||||
|
)
|
||||||
|
external_group = models.CharField(max_length=255)
|
||||||
|
user_group = models.ForeignKey(
|
||||||
|
UserGroup,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="enterprise_identity_mappings",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["provider_id", "external_group"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["provider", "external_group"],
|
||||||
|
name="acct_idp_external_group_uniq",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EnterpriseDirectorySyncRun(models.Model):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
RUNNING = "running", "运行中"
|
||||||
|
COMPLETED = "completed", "已完成"
|
||||||
|
PARTIAL = "partial", "部分完成"
|
||||||
|
FAILED = "failed", "失败"
|
||||||
|
|
||||||
|
provider = models.ForeignKey(
|
||||||
|
EnterpriseIdentityProvider,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="directory_sync_runs",
|
||||||
|
)
|
||||||
|
status = models.CharField(max_length=20, choices=Status.choices, default=Status.RUNNING)
|
||||||
|
total_count = models.PositiveIntegerField(default=0)
|
||||||
|
created_count = models.PositiveIntegerField(default=0)
|
||||||
|
updated_count = models.PositiveIntegerField(default=0)
|
||||||
|
failed_count = models.PositiveIntegerField(default=0)
|
||||||
|
errors = models.JSONField(default=list, blank=True)
|
||||||
|
initiated_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="enterprise_directory_sync_runs",
|
||||||
|
)
|
||||||
|
started_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
finished_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-started_at", "-id"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["provider", "started_at"], name="acct_dirsync_idp_started_idx"),
|
||||||
|
]
|
||||||
|
|||||||
72
backend/apps/accounts/oidc.py
Normal file
72
backend/apps/accounts/oidc.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""OIDC 客户端构建和安全重定向辅助函数。"""
|
||||||
|
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from apps.accounts.models import EnterpriseIdentityProvider
|
||||||
|
from apps.modelhub.crypto import decrypt_secret
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCConfigurationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def provider_metadata_url(provider: EnterpriseIdentityProvider) -> str:
|
||||||
|
if provider.metadata_url:
|
||||||
|
return provider.metadata_url
|
||||||
|
if provider.issuer:
|
||||||
|
return f"{provider.issuer.rstrip('/')}/.well-known/openid-configuration"
|
||||||
|
raise OIDCConfigurationError("OIDC 提供方缺少发现文档地址或 issuer")
|
||||||
|
|
||||||
|
|
||||||
|
def build_oidc_client(provider: EnterpriseIdentityProvider):
|
||||||
|
"""构建依赖发现文档和加密凭据的 OIDC 客户端。"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from authlib.integrations.django_client import OAuth
|
||||||
|
except ImportError as error:
|
||||||
|
raise OIDCConfigurationError("服务端未安装 Authlib") from error
|
||||||
|
|
||||||
|
if not provider.client_id:
|
||||||
|
raise OIDCConfigurationError("OIDC 提供方缺少 client_id")
|
||||||
|
if not provider.credential_encrypted:
|
||||||
|
raise OIDCConfigurationError("OIDC 提供方缺少 client secret")
|
||||||
|
scopes = provider.scopes or ["openid", "profile", "email"]
|
||||||
|
if "openid" not in scopes:
|
||||||
|
raise OIDCConfigurationError("OIDC scopes 必须包含 openid")
|
||||||
|
oauth = OAuth()
|
||||||
|
return oauth.register(
|
||||||
|
name=f"enterprise_oidc_{provider.pk}",
|
||||||
|
client_id=provider.client_id,
|
||||||
|
client_secret=decrypt_secret(provider.credential_encrypted),
|
||||||
|
server_metadata_url=provider_metadata_url(provider),
|
||||||
|
client_kwargs={
|
||||||
|
"scope": " ".join(scopes),
|
||||||
|
"token_endpoint_auth_method": provider.configuration.get(
|
||||||
|
"token_endpoint_auth_method",
|
||||||
|
"client_secret_basic",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def callback_uri(request, provider: EnterpriseIdentityProvider) -> str:
|
||||||
|
path = f"/api/identity/auth/oidc/{provider.slug}/callback"
|
||||||
|
if settings.OIDC_CALLBACK_BASE_URL:
|
||||||
|
return f"{settings.OIDC_CALLBACK_BASE_URL}{path}"
|
||||||
|
return request.build_absolute_uri(path)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_return_to(value: str | None) -> str:
|
||||||
|
"""只允许站内绝对路径,阻断协议相对地址和外部开放重定向。"""
|
||||||
|
|
||||||
|
if not value or not value.startswith("/") or value.startswith("//") or len(value) > 500:
|
||||||
|
return "/"
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def frontend_redirect(return_to: str = "/", error: str = "") -> str:
|
||||||
|
if error:
|
||||||
|
return f"{settings.FRONTEND_APP_URL}/#/login?auth_error={quote(error[:240])}"
|
||||||
|
return f"{settings.FRONTEND_APP_URL}/#{normalize_return_to(return_to)}"
|
||||||
238
backend/apps/accounts/tests/test_enterprise_identity.py
Normal file
238
backend/apps/accounts/tests/test_enterprise_identity.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
|
||||||
|
from apps.accounts.models import (
|
||||||
|
EnterpriseGroupMapping,
|
||||||
|
EnterpriseIdentityProvider,
|
||||||
|
ExternalIdentity,
|
||||||
|
UserGroup,
|
||||||
|
)
|
||||||
|
from apps.audit.models import OperationAuditLog
|
||||||
|
from apps.modelhub.crypto import encrypt_secret
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def login_admin(client):
|
||||||
|
call_command("bootstrap_governance")
|
||||||
|
admin = get_user_model().objects.get(username="admin")
|
||||||
|
client.force_login(admin)
|
||||||
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
def oidc_provider(**overrides):
|
||||||
|
values = {
|
||||||
|
"slug": "corporate",
|
||||||
|
"name": "企业统一身份",
|
||||||
|
"provider_type": "oidc",
|
||||||
|
"enabled": True,
|
||||||
|
"issuer": "https://idp.example.com",
|
||||||
|
"client_id": "ticket-platform",
|
||||||
|
"credential_encrypted": encrypt_secret("client-secret"),
|
||||||
|
"scopes": ["openid", "profile", "email"],
|
||||||
|
"claim_mappings": {},
|
||||||
|
"configuration": {},
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return EnterpriseIdentityProvider.objects.create(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_management_encrypts_credentials_and_exposes_safe_public_list(client):
|
||||||
|
login_admin(client)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/identity/providers",
|
||||||
|
data={
|
||||||
|
"slug": "corp-oidc",
|
||||||
|
"name": "公司 SSO",
|
||||||
|
"provider_type": "oidc",
|
||||||
|
"enabled": True,
|
||||||
|
"issuer": "https://idp.example.com",
|
||||||
|
"client_id": "ticket-platform",
|
||||||
|
"credential": "top-secret-value",
|
||||||
|
"scopes": ["openid", "profile", "email"],
|
||||||
|
},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["credential_set"] is True
|
||||||
|
assert "top-secret-value" not in str(payload)
|
||||||
|
provider = EnterpriseIdentityProvider.objects.get(slug="corp-oidc")
|
||||||
|
assert provider.credential_encrypted != "top-secret-value"
|
||||||
|
|
||||||
|
client.logout()
|
||||||
|
public_response = client.get("/api/identity/auth/providers")
|
||||||
|
assert public_response.status_code == 200
|
||||||
|
assert public_response.json() == [
|
||||||
|
{
|
||||||
|
"slug": "corp-oidc",
|
||||||
|
"name": "公司 SSO",
|
||||||
|
"button_label": "使用 公司 SSO 登录",
|
||||||
|
"provider_type": "oidc",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_rejects_plaintext_secrets_inside_configuration(client):
|
||||||
|
login_admin(client)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/identity/providers",
|
||||||
|
data={
|
||||||
|
"slug": "unsafe",
|
||||||
|
"name": "不安全配置",
|
||||||
|
"provider_type": "ldap",
|
||||||
|
"configuration": {
|
||||||
|
"server_uri": "ldaps://directory.example.com",
|
||||||
|
"bind_password": "must-not-be-plain",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert not EnterpriseIdentityProvider.objects.filter(slug="unsafe").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_sync_is_idempotent_and_maps_enterprise_groups(client):
|
||||||
|
login_admin(client)
|
||||||
|
provider = oidc_provider(
|
||||||
|
enabled=False,
|
||||||
|
claim_mappings={
|
||||||
|
"subject": "employee.id",
|
||||||
|
"username": "employee.account",
|
||||||
|
"email": "employee.mail",
|
||||||
|
"display_name": "employee.name",
|
||||||
|
"groups": "organization.memberships",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
support = UserGroup.objects.create(name="一线支持", code="support_l1")
|
||||||
|
EnterpriseGroupMapping.objects.create(
|
||||||
|
provider=provider,
|
||||||
|
external_group="IT-Support",
|
||||||
|
user_group=support,
|
||||||
|
)
|
||||||
|
url = f"/api/identity/providers/{provider.id}/directory-sync"
|
||||||
|
record = {
|
||||||
|
"subject": "employee-1001",
|
||||||
|
"username": "zhangsan",
|
||||||
|
"email": "zhangsan@example.com",
|
||||||
|
"display_name": "张三",
|
||||||
|
"groups": ["IT-Support"],
|
||||||
|
}
|
||||||
|
|
||||||
|
first = client.post(
|
||||||
|
url,
|
||||||
|
data={"records": [record]},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
second = client.post(
|
||||||
|
url,
|
||||||
|
data={"records": [{**record, "display_name": "张三(支持)"}]},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert first.json()["created_count"] == 1
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert second.json()["updated_count"] == 1
|
||||||
|
assert ExternalIdentity.objects.filter(provider=provider).count() == 1
|
||||||
|
identity = ExternalIdentity.objects.select_related("user").get(provider=provider)
|
||||||
|
assert identity.user.first_name == "张三(支持)"
|
||||||
|
assert identity.user.business_groups.filter(id=support.id).exists()
|
||||||
|
assert not identity.user.has_usable_password()
|
||||||
|
|
||||||
|
|
||||||
|
def test_oidc_callback_logs_in_user_and_reuses_external_identity(client):
|
||||||
|
provider = oidc_provider()
|
||||||
|
support = UserGroup.objects.create(name="企业支持组", code="enterprise_support")
|
||||||
|
EnterpriseGroupMapping.objects.create(
|
||||||
|
provider=provider,
|
||||||
|
external_group="support",
|
||||||
|
user_group=support,
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeOIDCClient:
|
||||||
|
def authorize_access_token(self, request):
|
||||||
|
return {
|
||||||
|
"userinfo": {
|
||||||
|
"sub": "stable-subject",
|
||||||
|
"preferred_username": "corp-user",
|
||||||
|
"email": "corp-user@example.com",
|
||||||
|
"name": "企业用户",
|
||||||
|
"groups": ["support"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session = client.session
|
||||||
|
session[f"identity_return_to:{provider.slug}"] = "/tickets"
|
||||||
|
session.save()
|
||||||
|
callback = f"/api/identity/auth/oidc/{provider.slug}/callback"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.accounts.identity_api.build_oidc_client",
|
||||||
|
return_value=FakeOIDCClient(),
|
||||||
|
):
|
||||||
|
first = client.get(callback)
|
||||||
|
client.logout()
|
||||||
|
second = client.get(callback)
|
||||||
|
|
||||||
|
assert first.status_code == 302
|
||||||
|
assert first.url.endswith("/#/tickets")
|
||||||
|
assert second.status_code == 302
|
||||||
|
assert ExternalIdentity.objects.filter(provider=provider).count() == 1
|
||||||
|
identity = ExternalIdentity.objects.select_related("user").get(provider=provider)
|
||||||
|
assert identity.user.business_groups.filter(id=support.id).exists()
|
||||||
|
assert OperationAuditLog.objects.filter(
|
||||||
|
action="oidc_login",
|
||||||
|
target_id=str(identity.id),
|
||||||
|
).count() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_oidc_start_uses_server_side_client_and_callback_failure_is_safe(client):
|
||||||
|
provider = oidc_provider()
|
||||||
|
|
||||||
|
class FakeOIDCClient:
|
||||||
|
def authorize_redirect(self, request, redirect_uri):
|
||||||
|
assert redirect_uri.endswith(f"/api/identity/auth/oidc/{provider.slug}/callback")
|
||||||
|
return HttpResponseRedirect("https://idp.example.com/authorize?state=managed")
|
||||||
|
|
||||||
|
def authorize_access_token(self, request):
|
||||||
|
return {"access_token": "must-never-be-audited"}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.accounts.identity_api.build_oidc_client",
|
||||||
|
return_value=FakeOIDCClient(),
|
||||||
|
):
|
||||||
|
start = client.get(
|
||||||
|
f"/api/identity/auth/oidc/{provider.slug}/start",
|
||||||
|
{"return_to": "//evil.example.com"},
|
||||||
|
)
|
||||||
|
callback = client.get(f"/api/identity/auth/oidc/{provider.slug}/callback")
|
||||||
|
|
||||||
|
assert start.status_code == 302
|
||||||
|
assert start.url.startswith("https://idp.example.com/authorize")
|
||||||
|
assert callback.status_code == 302
|
||||||
|
assert "auth_error=" in callback.url
|
||||||
|
audit = OperationAuditLog.objects.get(action="oidc_callback_failed")
|
||||||
|
assert "must-never-be-audited" not in str(audit.request_snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def test_identity_governance_requires_permission(client):
|
||||||
|
provider = oidc_provider(enabled=False)
|
||||||
|
|
||||||
|
assert client.get("/api/identity/providers").status_code == 401
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"/api/identity/providers/{provider.id}/directory-sync",
|
||||||
|
data={"records": []},
|
||||||
|
content_type="application/json",
|
||||||
|
).status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
1
backend/apps/channels/__init__.py
Normal file
1
backend/apps/channels/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
242
backend/apps/channels/api.py
Normal file
242
backend/apps/channels/api.py
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
from django.db import transaction
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
from apps.accounts.api import require_permission
|
||||||
|
from apps.channels.models import ChannelEndpoint, ChannelMessage
|
||||||
|
from apps.channels.schemas import (
|
||||||
|
ChannelEndpointIn,
|
||||||
|
ChannelEndpointOut,
|
||||||
|
ChannelEndpointUpdateIn,
|
||||||
|
ChannelInboundIn,
|
||||||
|
ChannelMessageOut,
|
||||||
|
ChannelOutboundIn,
|
||||||
|
ChannelReceiptIn,
|
||||||
|
)
|
||||||
|
from apps.channels.services import (
|
||||||
|
ChannelError,
|
||||||
|
ingest_inbound_message,
|
||||||
|
queue_outbound_message,
|
||||||
|
record_channel_receipt,
|
||||||
|
verify_webhook_signature,
|
||||||
|
)
|
||||||
|
from apps.modelhub.crypto import encrypt_secret
|
||||||
|
|
||||||
|
router = Router(tags=["channels"])
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_endpoint(endpoint: ChannelEndpoint) -> dict:
|
||||||
|
return {
|
||||||
|
"id": endpoint.id,
|
||||||
|
"public_id": endpoint.public_id,
|
||||||
|
"name": endpoint.name,
|
||||||
|
"adapter_type": endpoint.adapter_type,
|
||||||
|
"outbound_url": endpoint.outbound_url,
|
||||||
|
"webhook_secret_set": bool(endpoint.webhook_secret_encrypted),
|
||||||
|
"outbound_token_set": bool(endpoint.outbound_token_encrypted),
|
||||||
|
"enabled": endpoint.enabled,
|
||||||
|
"created_at": endpoint.created_at,
|
||||||
|
"updated_at": endpoint.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_message(message: ChannelMessage) -> dict:
|
||||||
|
return {
|
||||||
|
"public_id": message.public_id,
|
||||||
|
"endpoint_id": message.endpoint_id,
|
||||||
|
"endpoint_name": message.endpoint.name,
|
||||||
|
"conversation_public_id": (
|
||||||
|
message.conversation.public_id if message.conversation else None
|
||||||
|
),
|
||||||
|
"external_conversation_id": (
|
||||||
|
message.conversation.external_conversation_id
|
||||||
|
if message.conversation
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"ticket_id": message.ticket_id,
|
||||||
|
"customer_reply_task_id": message.customer_reply_task_id,
|
||||||
|
"reply_revision": message.reply_revision,
|
||||||
|
"direction": message.direction,
|
||||||
|
"external_message_id": message.external_message_id,
|
||||||
|
"idempotency_key": message.idempotency_key,
|
||||||
|
"sender": message.sender,
|
||||||
|
"message_type": message.message_type,
|
||||||
|
"text": message.text,
|
||||||
|
"payload": message.payload,
|
||||||
|
"status": message.status,
|
||||||
|
"error_message": message.error_message,
|
||||||
|
"occurred_at": message.occurred_at,
|
||||||
|
"sent_at": message.sent_at,
|
||||||
|
"delivered_at": message.delivered_at,
|
||||||
|
"created_at": message.created_at,
|
||||||
|
"updated_at": message.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_endpoint(public_id: str) -> ChannelEndpoint:
|
||||||
|
endpoint = ChannelEndpoint.objects.filter(public_id=public_id).first()
|
||||||
|
if endpoint is None:
|
||||||
|
raise HttpError(404, "渠道不存在")
|
||||||
|
return endpoint
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/endpoints", response=list[ChannelEndpointOut])
|
||||||
|
def list_channel_endpoints(request):
|
||||||
|
require_permission(request, "channel.view")
|
||||||
|
return [serialize_endpoint(item) for item in ChannelEndpoint.objects.all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/endpoints", response=ChannelEndpointOut)
|
||||||
|
def create_channel_endpoint(request, payload: ChannelEndpointIn):
|
||||||
|
require_permission(request, "channel.manage")
|
||||||
|
if payload.adapter_type != "generic_webhook":
|
||||||
|
raise HttpError(400, "首期仅支持 generic_webhook 适配器")
|
||||||
|
if not payload.webhook_secret.strip():
|
||||||
|
raise HttpError(400, "Webhook 密钥不能为空")
|
||||||
|
endpoint = ChannelEndpoint.objects.create(
|
||||||
|
name=payload.name,
|
||||||
|
adapter_type=payload.adapter_type,
|
||||||
|
outbound_url=payload.outbound_url,
|
||||||
|
webhook_secret_encrypted=encrypt_secret(payload.webhook_secret),
|
||||||
|
outbound_token_encrypted=(
|
||||||
|
encrypt_secret(payload.outbound_token) if payload.outbound_token else ""
|
||||||
|
),
|
||||||
|
enabled=payload.enabled,
|
||||||
|
created_by=request.user,
|
||||||
|
)
|
||||||
|
return serialize_endpoint(endpoint)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/endpoints/{endpoint_id}", response=ChannelEndpointOut)
|
||||||
|
def update_channel_endpoint(
|
||||||
|
request,
|
||||||
|
endpoint_id: int,
|
||||||
|
payload: ChannelEndpointUpdateIn,
|
||||||
|
):
|
||||||
|
require_permission(request, "channel.manage")
|
||||||
|
endpoint = ChannelEndpoint.objects.filter(id=endpoint_id).first()
|
||||||
|
if endpoint is None:
|
||||||
|
raise HttpError(404, "渠道不存在")
|
||||||
|
changes = payload.dict(exclude_unset=True)
|
||||||
|
for field in ("name", "outbound_url", "enabled"):
|
||||||
|
if field in changes:
|
||||||
|
setattr(endpoint, field, changes[field])
|
||||||
|
if changes.get("webhook_secret"):
|
||||||
|
endpoint.webhook_secret_encrypted = encrypt_secret(changes["webhook_secret"])
|
||||||
|
if changes.get("outbound_token"):
|
||||||
|
endpoint.outbound_token_encrypted = encrypt_secret(changes["outbound_token"])
|
||||||
|
endpoint.save()
|
||||||
|
return serialize_endpoint(endpoint)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/messages", response=list[ChannelMessageOut])
|
||||||
|
def list_channel_messages(
|
||||||
|
request,
|
||||||
|
endpoint_id: int | None = None,
|
||||||
|
direction: str = "",
|
||||||
|
status: str = "",
|
||||||
|
):
|
||||||
|
require_permission(request, "channel.view")
|
||||||
|
queryset = ChannelMessage.objects.select_related("endpoint", "conversation")
|
||||||
|
if endpoint_id:
|
||||||
|
queryset = queryset.filter(endpoint_id=endpoint_id)
|
||||||
|
if direction:
|
||||||
|
queryset = queryset.filter(direction=direction)
|
||||||
|
if status:
|
||||||
|
queryset = queryset.filter(status=status)
|
||||||
|
return [serialize_message(item) for item in queryset[:100]]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/endpoints/{endpoint_id}/messages",
|
||||||
|
response=ChannelMessageOut,
|
||||||
|
)
|
||||||
|
def send_channel_message(request, endpoint_id: int, payload: ChannelOutboundIn):
|
||||||
|
require_permission(request, "channel.send")
|
||||||
|
require_permission(request, "ticket.reply")
|
||||||
|
endpoint = ChannelEndpoint.objects.filter(id=endpoint_id).first()
|
||||||
|
if endpoint is None:
|
||||||
|
raise HttpError(404, "渠道不存在")
|
||||||
|
try:
|
||||||
|
message = queue_outbound_message(
|
||||||
|
endpoint,
|
||||||
|
payload.dict(),
|
||||||
|
actor=request.user,
|
||||||
|
)
|
||||||
|
except ChannelError as error:
|
||||||
|
raise HttpError(400, str(error)) from error
|
||||||
|
message = ChannelMessage.objects.select_related("endpoint", "conversation").get(
|
||||||
|
id=message.id
|
||||||
|
)
|
||||||
|
return serialize_message(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/messages/{public_id}/retry", response=ChannelMessageOut)
|
||||||
|
def retry_channel_message(request, public_id: str):
|
||||||
|
require_permission(request, "channel.send")
|
||||||
|
require_permission(request, "ticket.reply")
|
||||||
|
message = (
|
||||||
|
ChannelMessage.objects.select_related("endpoint", "conversation")
|
||||||
|
.filter(public_id=public_id, direction="outbound")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if message is None:
|
||||||
|
raise HttpError(404, "出站消息不存在")
|
||||||
|
if message.status != "failed":
|
||||||
|
raise HttpError(409, "仅失败消息可以重试")
|
||||||
|
message.status = "queued"
|
||||||
|
message.error_message = ""
|
||||||
|
message.save(update_fields=["status", "error_message", "updated_at"])
|
||||||
|
|
||||||
|
def enqueue_delivery():
|
||||||
|
from apps.channels.tasks import deliver_channel_message_task
|
||||||
|
|
||||||
|
deliver_channel_message_task.delay(message.id)
|
||||||
|
|
||||||
|
transaction.on_commit(enqueue_delivery)
|
||||||
|
return serialize_message(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/webhooks/{public_id}/messages",
|
||||||
|
response=ChannelMessageOut,
|
||||||
|
auth=None,
|
||||||
|
)
|
||||||
|
def receive_channel_message(request, public_id: str, payload: ChannelInboundIn):
|
||||||
|
endpoint = get_endpoint(public_id)
|
||||||
|
try:
|
||||||
|
verify_webhook_signature(
|
||||||
|
endpoint,
|
||||||
|
request.body,
|
||||||
|
request.headers.get("X-Channel-Signature", ""),
|
||||||
|
)
|
||||||
|
except ChannelError as error:
|
||||||
|
raise HttpError(401, str(error)) from error
|
||||||
|
try:
|
||||||
|
message = ingest_inbound_message(endpoint, payload.dict())
|
||||||
|
except ChannelError as error:
|
||||||
|
raise HttpError(400, str(error)) from error
|
||||||
|
return serialize_message(
|
||||||
|
ChannelMessage.objects.select_related("endpoint", "conversation").get(
|
||||||
|
id=message.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/webhooks/{public_id}/receipts",
|
||||||
|
response={200: dict},
|
||||||
|
auth=None,
|
||||||
|
)
|
||||||
|
def receive_channel_receipt(request, public_id: str, payload: ChannelReceiptIn):
|
||||||
|
endpoint = get_endpoint(public_id)
|
||||||
|
try:
|
||||||
|
verify_webhook_signature(
|
||||||
|
endpoint,
|
||||||
|
request.body,
|
||||||
|
request.headers.get("X-Channel-Signature", ""),
|
||||||
|
)
|
||||||
|
receipt = record_channel_receipt(endpoint, payload.dict())
|
||||||
|
except ChannelError as error:
|
||||||
|
raise HttpError(400, str(error)) from error
|
||||||
|
return 200, {"status": "accepted", "receipt_id": receipt.id}
|
||||||
6
backend/apps/channels/apps.py
Normal file
6
backend/apps/channels/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.channels"
|
||||||
112
backend/apps/channels/migrations/0001_initial.py
Normal file
112
backend/apps/channels/migrations/0001_initial.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2026-07-27
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("tickets", "0013_ticketsupportanalysis_applied_solution_and_more"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ChannelEndpoint",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
("name", models.CharField(max_length=120, unique=True)),
|
||||||
|
("adapter_type", models.CharField(default="generic_webhook", max_length=40)),
|
||||||
|
("outbound_url", models.URLField(blank=True, max_length=500)),
|
||||||
|
("webhook_secret_encrypted", models.TextField(blank=True)),
|
||||||
|
("outbound_token_encrypted", models.TextField(blank=True)),
|
||||||
|
("enabled", models.BooleanField(default=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("created_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_channel_endpoints", to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={"ordering": ["name"]},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ChannelConversation",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
("external_conversation_id", models.CharField(max_length=200)),
|
||||||
|
("title", models.CharField(blank=True, max_length=200)),
|
||||||
|
("participants", models.JSONField(blank=True, default=list)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("endpoint", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="conversations", to="channels.channelendpoint")),
|
||||||
|
("ticket", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="channel_conversations", to="tickets.ticket")),
|
||||||
|
],
|
||||||
|
options={"ordering": ["-updated_at"]},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ChannelMessage",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
("direction", models.CharField(max_length=16)),
|
||||||
|
("external_message_id", models.CharField(blank=True, max_length=200)),
|
||||||
|
("idempotency_key", models.CharField(blank=True, max_length=200)),
|
||||||
|
("sender", models.CharField(blank=True, max_length=160)),
|
||||||
|
("message_type", models.CharField(default="text", max_length=24)),
|
||||||
|
("text", models.TextField(blank=True)),
|
||||||
|
("payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("status", models.CharField(max_length=24)),
|
||||||
|
("error_message", models.TextField(blank=True)),
|
||||||
|
("occurred_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("sent_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("delivered_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("conversation", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="messages", to="channels.channelconversation")),
|
||||||
|
("created_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_channel_messages", to=settings.AUTH_USER_MODEL)),
|
||||||
|
("endpoint", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="messages", to="channels.channelendpoint")),
|
||||||
|
("ticket", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="channel_messages", to="tickets.ticket")),
|
||||||
|
],
|
||||||
|
options={"ordering": ["-created_at"]},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ChannelReceipt",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("receipt_type", models.CharField(max_length=24)),
|
||||||
|
("external_receipt_id", models.CharField(max_length=200)),
|
||||||
|
("payload", models.JSONField(blank=True, default=dict)),
|
||||||
|
("received_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("message", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="receipts", to="channels.channelmessage")),
|
||||||
|
],
|
||||||
|
options={"ordering": ["received_at"]},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="channelconversation",
|
||||||
|
constraint=models.UniqueConstraint(fields=("endpoint", "external_conversation_id"), name="unique_channel_external_conversation"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="channelmessage",
|
||||||
|
index=models.Index(fields=["endpoint", "direction", "created_at"], name="channels_ch_endpoin_30d460_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="channelmessage",
|
||||||
|
index=models.Index(fields=["status", "created_at"], name="channels_ch_status_eeefec_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="channelmessage",
|
||||||
|
constraint=models.UniqueConstraint(condition=models.Q(("external_message_id__gt", "")), fields=("endpoint", "direction", "external_message_id"), name="unique_channel_external_message"),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="channelmessage",
|
||||||
|
constraint=models.UniqueConstraint(condition=models.Q(("idempotency_key__gt", "")), fields=("endpoint", "direction", "idempotency_key"), name="unique_channel_message_idempotency"),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="channelreceipt",
|
||||||
|
constraint=models.UniqueConstraint(fields=("message", "receipt_type", "external_receipt_id"), name="unique_channel_message_receipt"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("channels", "0001_initial"),
|
||||||
|
("tickets", "0014_customerreplytask_channel_approval"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="channelmessage",
|
||||||
|
name="customer_reply_task",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="channel_messages",
|
||||||
|
to="tickets.customerreplytask",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="channelmessage",
|
||||||
|
name="reply_revision",
|
||||||
|
field=models.PositiveIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="channelmessage",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("customer_reply_task", "reply_revision"),
|
||||||
|
condition=Q(
|
||||||
|
direction="outbound",
|
||||||
|
customer_reply_task__isnull=False,
|
||||||
|
),
|
||||||
|
name="unique_channel_reply_revision",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
1
backend/apps/channels/migrations/__init__.py
Normal file
1
backend/apps/channels/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
157
backend/apps/channels/models.py
Normal file
157
backend/apps/channels/models.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelEndpoint(models.Model):
|
||||||
|
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||||
|
name = models.CharField(max_length=120, unique=True)
|
||||||
|
adapter_type = models.CharField(max_length=40, default="generic_webhook")
|
||||||
|
outbound_url = models.URLField(max_length=500, blank=True)
|
||||||
|
webhook_secret_encrypted = models.TextField(blank=True)
|
||||||
|
outbound_token_encrypted = models.TextField(blank=True)
|
||||||
|
enabled = models.BooleanField(default=True)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="created_channel_endpoints",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelConversation(models.Model):
|
||||||
|
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||||
|
endpoint = models.ForeignKey(
|
||||||
|
ChannelEndpoint,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="conversations",
|
||||||
|
)
|
||||||
|
external_conversation_id = models.CharField(max_length=200)
|
||||||
|
title = models.CharField(max_length=200, blank=True)
|
||||||
|
participants = models.JSONField(default=list, blank=True)
|
||||||
|
ticket = models.ForeignKey(
|
||||||
|
"tickets.Ticket",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="channel_conversations",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-updated_at"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["endpoint", "external_conversation_id"],
|
||||||
|
name="unique_channel_external_conversation",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelMessage(models.Model):
|
||||||
|
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||||
|
endpoint = models.ForeignKey(
|
||||||
|
ChannelEndpoint,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="messages",
|
||||||
|
)
|
||||||
|
conversation = models.ForeignKey(
|
||||||
|
ChannelConversation,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="messages",
|
||||||
|
)
|
||||||
|
ticket = models.ForeignKey(
|
||||||
|
"tickets.Ticket",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="channel_messages",
|
||||||
|
)
|
||||||
|
customer_reply_task = models.ForeignKey(
|
||||||
|
"tickets.CustomerReplyTask",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="channel_messages",
|
||||||
|
)
|
||||||
|
reply_revision = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
direction = models.CharField(max_length=16)
|
||||||
|
external_message_id = models.CharField(max_length=200, blank=True)
|
||||||
|
idempotency_key = models.CharField(max_length=200, blank=True)
|
||||||
|
sender = models.CharField(max_length=160, blank=True)
|
||||||
|
message_type = models.CharField(max_length=24, default="text")
|
||||||
|
text = models.TextField(blank=True)
|
||||||
|
payload = models.JSONField(default=dict, blank=True)
|
||||||
|
status = models.CharField(max_length=24)
|
||||||
|
error_message = models.TextField(blank=True)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="created_channel_messages",
|
||||||
|
)
|
||||||
|
occurred_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
delivered_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["endpoint", "direction", "external_message_id"],
|
||||||
|
condition=Q(external_message_id__gt=""),
|
||||||
|
name="unique_channel_external_message",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["endpoint", "direction", "idempotency_key"],
|
||||||
|
condition=Q(idempotency_key__gt=""),
|
||||||
|
name="unique_channel_message_idempotency",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["customer_reply_task", "reply_revision"],
|
||||||
|
condition=Q(
|
||||||
|
direction="outbound",
|
||||||
|
customer_reply_task__isnull=False,
|
||||||
|
),
|
||||||
|
name="unique_channel_reply_revision",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["endpoint", "direction", "created_at"]),
|
||||||
|
models.Index(fields=["status", "created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelReceipt(models.Model):
|
||||||
|
message = models.ForeignKey(
|
||||||
|
ChannelMessage,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="receipts",
|
||||||
|
)
|
||||||
|
receipt_type = models.CharField(max_length=24)
|
||||||
|
external_receipt_id = models.CharField(max_length=200)
|
||||||
|
payload = models.JSONField(default=dict, blank=True)
|
||||||
|
received_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["received_at"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["message", "receipt_type", "external_receipt_id"],
|
||||||
|
name="unique_channel_message_receipt",
|
||||||
|
)
|
||||||
|
]
|
||||||
86
backend/apps/channels/schemas.py
Normal file
86
backend/apps/channels/schemas.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelEndpointIn(Schema):
|
||||||
|
name: str
|
||||||
|
adapter_type: str = "generic_webhook"
|
||||||
|
outbound_url: str = ""
|
||||||
|
webhook_secret: str
|
||||||
|
outbound_token: str = ""
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelEndpointOut(Schema):
|
||||||
|
id: int
|
||||||
|
public_id: UUID
|
||||||
|
name: str
|
||||||
|
adapter_type: str
|
||||||
|
outbound_url: str
|
||||||
|
webhook_secret_set: bool
|
||||||
|
outbound_token_set: bool
|
||||||
|
enabled: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelEndpointUpdateIn(Schema):
|
||||||
|
name: str | None = None
|
||||||
|
outbound_url: str | None = None
|
||||||
|
webhook_secret: str | None = None
|
||||||
|
outbound_token: str | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelInboundIn(Schema):
|
||||||
|
external_message_id: str
|
||||||
|
external_conversation_id: str
|
||||||
|
text: str
|
||||||
|
sender: str = ""
|
||||||
|
title: str = ""
|
||||||
|
participants: list[str] = []
|
||||||
|
occurred_at: datetime | None = None
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelReceiptIn(Schema):
|
||||||
|
external_message_id: str
|
||||||
|
external_receipt_id: str
|
||||||
|
receipt_type: str
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelOutboundIn(Schema):
|
||||||
|
external_conversation_id: str
|
||||||
|
idempotency_key: str
|
||||||
|
customer_reply_task_id: int
|
||||||
|
expected_revision: int
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelMessageOut(Schema):
|
||||||
|
public_id: UUID
|
||||||
|
endpoint_id: int
|
||||||
|
endpoint_name: str
|
||||||
|
conversation_public_id: UUID | None
|
||||||
|
external_conversation_id: str
|
||||||
|
ticket_id: int | None
|
||||||
|
customer_reply_task_id: int | None
|
||||||
|
reply_revision: int | None
|
||||||
|
direction: str
|
||||||
|
external_message_id: str
|
||||||
|
idempotency_key: str
|
||||||
|
sender: str
|
||||||
|
message_type: str
|
||||||
|
text: str
|
||||||
|
payload: dict[str, Any]
|
||||||
|
status: str
|
||||||
|
error_message: str
|
||||||
|
occurred_at: datetime | None
|
||||||
|
sent_at: datetime | None
|
||||||
|
delivered_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
375
backend/apps/channels/services.py
Normal file
375
backend/apps/channels/services.py
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
"""企业渠道消息的签名校验、幂等收发、回执和客户回复任务联动。"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.audit.models import OperationAuditLog
|
||||||
|
from apps.channels.models import (
|
||||||
|
ChannelConversation,
|
||||||
|
ChannelEndpoint,
|
||||||
|
ChannelMessage,
|
||||||
|
ChannelReceipt,
|
||||||
|
)
|
||||||
|
from apps.modelhub.crypto import decrypt_secret
|
||||||
|
from apps.tickets.models import CustomerReplyTask, TicketActorSource
|
||||||
|
from apps.tickets.replies import (
|
||||||
|
CustomerReplyError,
|
||||||
|
approve_customer_reply_for_channel,
|
||||||
|
confirm_customer_reply_sent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def verify_webhook_signature(endpoint: ChannelEndpoint, body: bytes, signature: str) -> None:
|
||||||
|
"""使用常量时间比较验证原始请求体的 HMAC-SHA256 签名。"""
|
||||||
|
|
||||||
|
if not endpoint.webhook_secret_encrypted:
|
||||||
|
raise ChannelError("渠道未配置 Webhook 密钥")
|
||||||
|
secret = decrypt_secret(endpoint.webhook_secret_encrypted).encode()
|
||||||
|
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||||
|
normalized = signature.removeprefix("sha256=").strip()
|
||||||
|
if not normalized or not hmac.compare_digest(expected, normalized):
|
||||||
|
raise ChannelError("Webhook 签名无效")
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def ingest_inbound_message(endpoint: ChannelEndpoint, payload: dict) -> ChannelMessage:
|
||||||
|
"""以渠道消息 ID 幂等写入入站消息和会话,不在此处隐式创建工单。"""
|
||||||
|
|
||||||
|
endpoint = ChannelEndpoint.objects.select_for_update().get(id=endpoint.id)
|
||||||
|
if not endpoint.enabled:
|
||||||
|
raise ChannelError("渠道已停用")
|
||||||
|
external_message_id = str(payload.get("external_message_id") or "").strip()
|
||||||
|
conversation_id = str(payload.get("external_conversation_id") or "").strip()
|
||||||
|
text = str(payload.get("text") or "").strip()
|
||||||
|
if not external_message_id or not conversation_id or not text:
|
||||||
|
raise ChannelError("消息 ID、会话 ID 和文本不能为空")
|
||||||
|
existing = ChannelMessage.objects.filter(
|
||||||
|
endpoint=endpoint,
|
||||||
|
direction="inbound",
|
||||||
|
external_message_id=external_message_id,
|
||||||
|
).first()
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
conversation, _ = ChannelConversation.objects.get_or_create(
|
||||||
|
endpoint=endpoint,
|
||||||
|
external_conversation_id=conversation_id,
|
||||||
|
defaults={
|
||||||
|
"title": str(payload.get("title") or "")[:200],
|
||||||
|
"participants": payload.get("participants")
|
||||||
|
if isinstance(payload.get("participants"), list)
|
||||||
|
else [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
message = ChannelMessage.objects.create(
|
||||||
|
endpoint=endpoint,
|
||||||
|
conversation=conversation,
|
||||||
|
ticket=conversation.ticket,
|
||||||
|
direction="inbound",
|
||||||
|
external_message_id=external_message_id,
|
||||||
|
idempotency_key=external_message_id,
|
||||||
|
sender=str(payload.get("sender") or "")[:160],
|
||||||
|
text=text,
|
||||||
|
payload=payload.get("payload") if isinstance(payload.get("payload"), dict) else {},
|
||||||
|
status="received",
|
||||||
|
occurred_at=payload.get("occurred_at"),
|
||||||
|
)
|
||||||
|
OperationAuditLog.objects.create(
|
||||||
|
module="channels",
|
||||||
|
action="message_received",
|
||||||
|
target_type="channel_message",
|
||||||
|
target_id=str(message.public_id),
|
||||||
|
request_snapshot={
|
||||||
|
"endpoint_id": endpoint.id,
|
||||||
|
"external_message_id": external_message_id,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
},
|
||||||
|
result="success",
|
||||||
|
)
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_outbound_url(value: str) -> None:
|
||||||
|
parsed = urlparse(value)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username:
|
||||||
|
raise ChannelError("出站地址必须是无内嵌凭证的 HTTP(S) URL")
|
||||||
|
|
||||||
|
|
||||||
|
def _confirm_delivered_reply(message: ChannelMessage) -> None:
|
||||||
|
if not message.customer_reply_task_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
confirm_customer_reply_sent(
|
||||||
|
message.customer_reply_task,
|
||||||
|
expected_revision=message.reply_revision,
|
||||||
|
actor=message.created_by,
|
||||||
|
source=TicketActorSource.CHANNEL,
|
||||||
|
channel_message_public_id=str(message.public_id),
|
||||||
|
)
|
||||||
|
except CustomerReplyError as error:
|
||||||
|
raise ChannelError(f"渠道已送达,但回复任务回写失败:{error}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def deliver_outbound_message(message_id: int) -> ChannelMessage:
|
||||||
|
"""投递已批准的回复版本,并只在明确送达后完成客户回复任务。"""
|
||||||
|
|
||||||
|
message = ChannelMessage.objects.select_related(
|
||||||
|
"endpoint",
|
||||||
|
"conversation",
|
||||||
|
"customer_reply_task",
|
||||||
|
).get(id=message_id)
|
||||||
|
endpoint = message.endpoint
|
||||||
|
if message.status == "delivered":
|
||||||
|
_confirm_delivered_reply(message)
|
||||||
|
return message
|
||||||
|
if message.status not in {"queued", "failed"}:
|
||||||
|
return message
|
||||||
|
if not endpoint.enabled or not endpoint.outbound_url:
|
||||||
|
message.status = "failed"
|
||||||
|
message.error_message = "渠道未启用或未配置出站地址"
|
||||||
|
message.save(update_fields=["status", "error_message", "updated_at"])
|
||||||
|
return message
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Idempotency-Key": message.idempotency_key,
|
||||||
|
}
|
||||||
|
if endpoint.outbound_token_encrypted:
|
||||||
|
headers["Authorization"] = (
|
||||||
|
f"Bearer {decrypt_secret(endpoint.outbound_token_encrypted)}"
|
||||||
|
)
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"conversation_id": message.conversation.external_conversation_id,
|
||||||
|
"text": message.text,
|
||||||
|
"message_id": str(message.public_id),
|
||||||
|
"metadata": message.payload,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode()
|
||||||
|
try:
|
||||||
|
_validate_outbound_url(endpoint.outbound_url)
|
||||||
|
request = Request(endpoint.outbound_url, data=body, headers=headers, method="POST")
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise ChannelError("渠道返回结构无效")
|
||||||
|
except (
|
||||||
|
HTTPError,
|
||||||
|
URLError,
|
||||||
|
TimeoutError,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
ChannelError,
|
||||||
|
) as error:
|
||||||
|
message.status = "failed"
|
||||||
|
message.error_message = str(error) or "渠道发送失败"
|
||||||
|
else:
|
||||||
|
message.status = str(result.get("status") or "sent")
|
||||||
|
if message.status not in {"sent", "delivered"}:
|
||||||
|
message.status = "sent"
|
||||||
|
message.external_message_id = str(result.get("external_message_id") or "")
|
||||||
|
message.error_message = ""
|
||||||
|
message.sent_at = timezone.now()
|
||||||
|
if message.status == "delivered":
|
||||||
|
message.delivered_at = timezone.now()
|
||||||
|
message.save()
|
||||||
|
if message.status == "delivered":
|
||||||
|
_confirm_delivered_reply(message)
|
||||||
|
OperationAuditLog.objects.create(
|
||||||
|
user=message.created_by,
|
||||||
|
module="channels",
|
||||||
|
action="message_delivery_finished",
|
||||||
|
target_type="channel_message",
|
||||||
|
target_id=str(message.public_id),
|
||||||
|
request_snapshot={
|
||||||
|
"endpoint_id": endpoint.id,
|
||||||
|
"status": message.status,
|
||||||
|
"idempotency_key": message.idempotency_key,
|
||||||
|
},
|
||||||
|
result="success" if message.status in {"sent", "delivered"} else "failed",
|
||||||
|
)
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def queue_outbound_message(
|
||||||
|
endpoint: ChannelEndpoint,
|
||||||
|
payload: dict,
|
||||||
|
*,
|
||||||
|
actor,
|
||||||
|
) -> ChannelMessage:
|
||||||
|
"""冻结客户回复版本并生成唯一出站消息,提交后再投递异步任务。"""
|
||||||
|
|
||||||
|
endpoint = ChannelEndpoint.objects.select_for_update().get(id=endpoint.id)
|
||||||
|
if not endpoint.enabled:
|
||||||
|
raise ChannelError("渠道已停用")
|
||||||
|
idempotency_key = str(payload.get("idempotency_key") or "").strip()
|
||||||
|
conversation_id = str(payload.get("external_conversation_id") or "").strip()
|
||||||
|
if not idempotency_key or not conversation_id:
|
||||||
|
raise ChannelError("幂等键和会话 ID 不能为空")
|
||||||
|
try:
|
||||||
|
reply_task_id = int(payload.get("customer_reply_task_id"))
|
||||||
|
expected_revision = int(payload.get("expected_revision"))
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise ChannelError("客户回复任务和版本不能为空") from error
|
||||||
|
existing = (
|
||||||
|
ChannelMessage.objects.select_related("conversation")
|
||||||
|
.filter(
|
||||||
|
endpoint=endpoint,
|
||||||
|
direction="outbound",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if (
|
||||||
|
existing.customer_reply_task_id == reply_task_id
|
||||||
|
and existing.reply_revision == expected_revision
|
||||||
|
and existing.conversation
|
||||||
|
and existing.conversation.external_conversation_id == conversation_id
|
||||||
|
):
|
||||||
|
return existing
|
||||||
|
raise ChannelError("幂等键已用于其他客户回复")
|
||||||
|
reply_task = (
|
||||||
|
CustomerReplyTask.objects.select_related("ticket", "support_analysis")
|
||||||
|
.filter(id=reply_task_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if reply_task is None:
|
||||||
|
raise ChannelError("客户回复任务不存在")
|
||||||
|
try:
|
||||||
|
reply_task = approve_customer_reply_for_channel(
|
||||||
|
reply_task,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
actor=actor,
|
||||||
|
channel_endpoint_id=endpoint.id,
|
||||||
|
external_conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
except CustomerReplyError as error:
|
||||||
|
raise ChannelError(str(error)) from error
|
||||||
|
existing_reply = (
|
||||||
|
ChannelMessage.objects.select_related("conversation")
|
||||||
|
.filter(
|
||||||
|
customer_reply_task=reply_task,
|
||||||
|
reply_revision=reply_task.revision,
|
||||||
|
direction="outbound",
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing_reply is not None:
|
||||||
|
if (
|
||||||
|
existing_reply.endpoint_id == endpoint.id
|
||||||
|
and existing_reply.conversation
|
||||||
|
and existing_reply.conversation.external_conversation_id == conversation_id
|
||||||
|
):
|
||||||
|
return existing_reply
|
||||||
|
raise ChannelError("该客户回复版本已进入其他渠道会话")
|
||||||
|
conversation, _ = ChannelConversation.objects.get_or_create(
|
||||||
|
endpoint=endpoint,
|
||||||
|
external_conversation_id=conversation_id,
|
||||||
|
)
|
||||||
|
if conversation.ticket_id and conversation.ticket_id != reply_task.ticket_id:
|
||||||
|
raise ChannelError("渠道会话已关联其他工单")
|
||||||
|
if not conversation.ticket_id:
|
||||||
|
conversation.ticket = reply_task.ticket
|
||||||
|
conversation.save(update_fields=["ticket", "updated_at"])
|
||||||
|
metadata = payload.get("payload") if isinstance(payload.get("payload"), dict) else {}
|
||||||
|
metadata = {
|
||||||
|
**metadata,
|
||||||
|
"customer_reply_task_id": reply_task.id,
|
||||||
|
"reply_revision": reply_task.revision,
|
||||||
|
}
|
||||||
|
message = ChannelMessage.objects.create(
|
||||||
|
endpoint=endpoint,
|
||||||
|
conversation=conversation,
|
||||||
|
ticket=reply_task.ticket,
|
||||||
|
customer_reply_task=reply_task,
|
||||||
|
reply_revision=reply_task.revision,
|
||||||
|
direction="outbound",
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
sender=actor.get_full_name() or actor.username,
|
||||||
|
text=reply_task.final_text,
|
||||||
|
payload=metadata,
|
||||||
|
status="queued",
|
||||||
|
created_by=actor,
|
||||||
|
)
|
||||||
|
OperationAuditLog.objects.create(
|
||||||
|
user=actor,
|
||||||
|
module="channels",
|
||||||
|
action="customer_reply_queued",
|
||||||
|
target_type="channel_message",
|
||||||
|
target_id=str(message.public_id),
|
||||||
|
request_snapshot={
|
||||||
|
"endpoint_id": endpoint.id,
|
||||||
|
"ticket_id": reply_task.ticket_id,
|
||||||
|
"customer_reply_task_id": reply_task.id,
|
||||||
|
"reply_revision": reply_task.revision,
|
||||||
|
"idempotency_key": idempotency_key,
|
||||||
|
},
|
||||||
|
result="success",
|
||||||
|
)
|
||||||
|
|
||||||
|
def enqueue_delivery():
|
||||||
|
from apps.channels.tasks import deliver_channel_message_task
|
||||||
|
|
||||||
|
deliver_channel_message_task.delay(message.id)
|
||||||
|
|
||||||
|
transaction.on_commit(enqueue_delivery)
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def record_channel_receipt(endpoint: ChannelEndpoint, payload: dict) -> ChannelReceipt:
|
||||||
|
"""幂等记录渠道回执并推进消息状态,重复回执不重复完成业务任务。"""
|
||||||
|
|
||||||
|
endpoint = ChannelEndpoint.objects.select_for_update().get(id=endpoint.id)
|
||||||
|
receipt_type = str(payload.get("receipt_type") or "").strip()
|
||||||
|
external_message_id = str(payload.get("external_message_id") or "").strip()
|
||||||
|
external_receipt_id = str(payload.get("external_receipt_id") or "").strip()
|
||||||
|
if receipt_type not in {"sent", "delivered", "read", "failed"}:
|
||||||
|
raise ChannelError("未知回执类型")
|
||||||
|
if not external_message_id or not external_receipt_id:
|
||||||
|
raise ChannelError("消息 ID 和回执 ID 不能为空")
|
||||||
|
message = ChannelMessage.objects.select_for_update().filter(
|
||||||
|
endpoint=endpoint,
|
||||||
|
direction="outbound",
|
||||||
|
external_message_id=external_message_id,
|
||||||
|
).first()
|
||||||
|
if message is None:
|
||||||
|
raise ChannelError("回执对应的出站消息不存在")
|
||||||
|
receipt, created = ChannelReceipt.objects.get_or_create(
|
||||||
|
message=message,
|
||||||
|
receipt_type=receipt_type,
|
||||||
|
external_receipt_id=external_receipt_id,
|
||||||
|
defaults={
|
||||||
|
"payload": payload.get("payload")
|
||||||
|
if isinstance(payload.get("payload"), dict)
|
||||||
|
else {}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
return receipt
|
||||||
|
if receipt_type == "failed":
|
||||||
|
message.status = "failed"
|
||||||
|
message.error_message = str(receipt.payload.get("reason") or "渠道返回失败回执")
|
||||||
|
elif receipt_type in {"delivered", "read"}:
|
||||||
|
message.status = "delivered"
|
||||||
|
message.delivered_at = timezone.now()
|
||||||
|
message.error_message = ""
|
||||||
|
elif message.status == "queued":
|
||||||
|
message.status = "sent"
|
||||||
|
message.sent_at = timezone.now()
|
||||||
|
message.save()
|
||||||
|
if message.status == "delivered":
|
||||||
|
_confirm_delivered_reply(message)
|
||||||
|
return receipt
|
||||||
8
backend/apps/channels/tasks.py
Normal file
8
backend/apps/channels/tasks.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
from apps.channels.services import deliver_outbound_message
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="apps.channels.tasks.deliver_channel_message")
|
||||||
|
def deliver_channel_message_task(message_id: int):
|
||||||
|
return deliver_outbound_message(message_id).status
|
||||||
1
backend/apps/channels/tests/__init__.py
Normal file
1
backend/apps/channels/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
286
backend/apps/channels/tests/test_channel_runtime.py
Normal file
286
backend/apps/channels/tests/test_channel_runtime.py
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from apps.audit.models import OperationAuditLog
|
||||||
|
from apps.channels.models import ChannelEndpoint, ChannelMessage, ChannelReceipt
|
||||||
|
from apps.channels.services import (
|
||||||
|
ChannelError,
|
||||||
|
queue_outbound_message,
|
||||||
|
record_channel_receipt,
|
||||||
|
)
|
||||||
|
from apps.modelhub.crypto import encrypt_secret
|
||||||
|
from apps.tickets.models import CustomerReplyTask, Ticket
|
||||||
|
from apps.workitems.models import WorkItem
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, payload: dict):
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return json.dumps(self.payload).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def signature(secret: str, body: bytes) -> str:
|
||||||
|
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||||
|
return f"sha256={digest}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_signed_inbound_webhook_is_idempotent(client):
|
||||||
|
secret = "channel-inbound-secret"
|
||||||
|
endpoint = ChannelEndpoint.objects.create(
|
||||||
|
name="客服群",
|
||||||
|
webhook_secret_encrypted=encrypt_secret(secret),
|
||||||
|
)
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"external_message_id": "msg-in-001",
|
||||||
|
"external_conversation_id": "group-001",
|
||||||
|
"text": "生产环境登录失败,错误码 AUTH-401",
|
||||||
|
"sender": "客户甲",
|
||||||
|
"participants": ["客户甲", "技术支持"],
|
||||||
|
"payload": {"source": "group"},
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode()
|
||||||
|
headers = {"HTTP_X_CHANNEL_SIGNATURE": signature(secret, body)}
|
||||||
|
|
||||||
|
first = client.post(
|
||||||
|
f"/api/channels/webhooks/{endpoint.public_id}/messages",
|
||||||
|
data=body,
|
||||||
|
content_type="application/json",
|
||||||
|
**headers,
|
||||||
|
)
|
||||||
|
second = client.post(
|
||||||
|
f"/api/channels/webhooks/{endpoint.public_id}/messages",
|
||||||
|
data=body,
|
||||||
|
content_type="application/json",
|
||||||
|
**headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert first.json()["public_id"] == second.json()["public_id"]
|
||||||
|
assert ChannelMessage.objects.count() == 1
|
||||||
|
message = ChannelMessage.objects.get()
|
||||||
|
assert message.status == "received"
|
||||||
|
assert message.conversation.external_conversation_id == "group-001"
|
||||||
|
assert OperationAuditLog.objects.filter(
|
||||||
|
module="channels",
|
||||||
|
action="message_received",
|
||||||
|
target_id=str(message.public_id),
|
||||||
|
).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_inbound_webhook_rejects_invalid_signature(client):
|
||||||
|
endpoint = ChannelEndpoint.objects.create(
|
||||||
|
name="签名渠道",
|
||||||
|
webhook_secret_encrypted=encrypt_secret("correct-secret"),
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
f"/api/channels/webhooks/{endpoint.public_id}/messages",
|
||||||
|
data={
|
||||||
|
"external_message_id": "msg-invalid",
|
||||||
|
"external_conversation_id": "group-invalid",
|
||||||
|
"text": "不应被接收",
|
||||||
|
},
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_X_CHANNEL_SIGNATURE="sha256=invalid",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert ChannelMessage.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
def test_outbound_delivery_and_receipt_are_idempotent(
|
||||||
|
admin_client,
|
||||||
|
django_capture_on_commit_callbacks,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
actor = get_user_model().objects.filter(is_superuser=True).first()
|
||||||
|
assert actor is not None
|
||||||
|
endpoint = ChannelEndpoint.objects.create(
|
||||||
|
name="出站渠道",
|
||||||
|
outbound_url="https://channel.example/messages",
|
||||||
|
webhook_secret_encrypted=encrypt_secret("receipt-secret"),
|
||||||
|
outbound_token_encrypted=encrypt_secret("outbound-token"),
|
||||||
|
)
|
||||||
|
ticket = Ticket.objects.create(
|
||||||
|
ticket_no="TK-CHANNEL-0001",
|
||||||
|
title="生产环境登录失败",
|
||||||
|
description="生产环境登录失败,错误码 AUTH-401",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
reply_task = CustomerReplyTask.objects.create(
|
||||||
|
ticket=ticket,
|
||||||
|
status="draft",
|
||||||
|
draft_text="已收到问题,正在处理。",
|
||||||
|
final_text="已收到问题,正在处理。",
|
||||||
|
created_by=actor,
|
||||||
|
)
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def fake_urlopen(request, timeout):
|
||||||
|
requests.append((request, timeout))
|
||||||
|
return FakeResponse(
|
||||||
|
{
|
||||||
|
"status": "sent",
|
||||||
|
"external_message_id": "external-out-001",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("apps.channels.services.urlopen", fake_urlopen)
|
||||||
|
payload = {
|
||||||
|
"external_conversation_id": "group-002",
|
||||||
|
"idempotency_key": "reply-001",
|
||||||
|
"customer_reply_task_id": reply_task.id,
|
||||||
|
"expected_revision": reply_task.revision,
|
||||||
|
"payload": {"template": "ack"},
|
||||||
|
}
|
||||||
|
|
||||||
|
with django_capture_on_commit_callbacks(execute=True):
|
||||||
|
first = queue_outbound_message(endpoint, payload, actor=actor)
|
||||||
|
with django_capture_on_commit_callbacks(execute=True):
|
||||||
|
second = queue_outbound_message(endpoint, payload, actor=actor)
|
||||||
|
|
||||||
|
first.refresh_from_db()
|
||||||
|
reply_task.refresh_from_db()
|
||||||
|
ticket.refresh_from_db()
|
||||||
|
assert first.id == second.id
|
||||||
|
assert first.status == "sent"
|
||||||
|
assert first.external_message_id == "external-out-001"
|
||||||
|
assert first.customer_reply_task == reply_task
|
||||||
|
assert first.reply_revision == 1
|
||||||
|
assert first.text == reply_task.final_text
|
||||||
|
assert reply_task.status == "approved"
|
||||||
|
assert reply_task.approved_by == actor
|
||||||
|
assert reply_task.approved_at is not None
|
||||||
|
assert ticket.replied_to_customer is False
|
||||||
|
assert ChannelMessage.objects.filter(direction="outbound").count() == 1
|
||||||
|
assert len(requests) == 1
|
||||||
|
request, timeout = requests[0]
|
||||||
|
assert timeout == 30
|
||||||
|
assert request.get_header("Idempotency-key") == "reply-001"
|
||||||
|
assert request.get_header("Authorization") == "Bearer outbound-token"
|
||||||
|
|
||||||
|
receipt_payload = {
|
||||||
|
"external_message_id": "external-out-001",
|
||||||
|
"external_receipt_id": "receipt-001",
|
||||||
|
"receipt_type": "delivered",
|
||||||
|
"payload": {"provider_status": "delivered"},
|
||||||
|
}
|
||||||
|
first_receipt = record_channel_receipt(endpoint, receipt_payload)
|
||||||
|
second_receipt = record_channel_receipt(endpoint, receipt_payload)
|
||||||
|
|
||||||
|
first.refresh_from_db()
|
||||||
|
reply_task.refresh_from_db()
|
||||||
|
ticket.refresh_from_db()
|
||||||
|
assert first_receipt.id == second_receipt.id
|
||||||
|
assert first.status == "delivered"
|
||||||
|
assert first.delivered_at is not None
|
||||||
|
assert reply_task.status == "sent"
|
||||||
|
assert reply_task.sent_at is not None
|
||||||
|
assert ticket.replied_to_customer is True
|
||||||
|
assert WorkItem.objects.filter(
|
||||||
|
ticket=ticket,
|
||||||
|
task_type="ticket_customer_reply",
|
||||||
|
status="completed",
|
||||||
|
).count() == 1
|
||||||
|
assert ChannelReceipt.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_customer_reply_version_cannot_be_queued_to_another_conversation(admin_client):
|
||||||
|
actor = get_user_model().objects.get(is_superuser=True)
|
||||||
|
endpoint = ChannelEndpoint.objects.create(
|
||||||
|
name="唯一回复渠道",
|
||||||
|
outbound_url="https://channel.example/messages",
|
||||||
|
)
|
||||||
|
ticket = Ticket.objects.create(
|
||||||
|
ticket_no="TK-CHANNEL-0002",
|
||||||
|
title="接口超时",
|
||||||
|
description="生产接口持续超时",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
reply_task = CustomerReplyTask.objects.create(
|
||||||
|
ticket=ticket,
|
||||||
|
draft_text="已定位到上游依赖超时。",
|
||||||
|
final_text="已定位到上游依赖超时。",
|
||||||
|
created_by=actor,
|
||||||
|
)
|
||||||
|
first_payload = {
|
||||||
|
"external_conversation_id": "group-a",
|
||||||
|
"idempotency_key": "reply-version-a",
|
||||||
|
"customer_reply_task_id": reply_task.id,
|
||||||
|
"expected_revision": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
queue_outbound_message(endpoint, first_payload, actor=actor)
|
||||||
|
|
||||||
|
with pytest.raises(ChannelError, match="已进入其他渠道会话"):
|
||||||
|
queue_outbound_message(
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
**first_payload,
|
||||||
|
"external_conversation_id": "group-b",
|
||||||
|
"idempotency_key": "reply-version-b",
|
||||||
|
},
|
||||||
|
actor=actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_outbound_api_rejects_arbitrary_text(admin_client):
|
||||||
|
endpoint = ChannelEndpoint.objects.create(
|
||||||
|
name="受控发送渠道",
|
||||||
|
outbound_url="https://channel.example/messages",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = admin_client.post(
|
||||||
|
f"/api/channels/endpoints/{endpoint.id}/messages",
|
||||||
|
{
|
||||||
|
"external_conversation_id": "group-uncontrolled",
|
||||||
|
"idempotency_key": "uncontrolled-001",
|
||||||
|
"ticket_id": 1,
|
||||||
|
"text": "绕过客户回复任务直接发送",
|
||||||
|
},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert ChannelMessage.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_channel_management_api_hides_credentials(admin_client):
|
||||||
|
response = admin_client.post(
|
||||||
|
"/api/channels/endpoints",
|
||||||
|
{
|
||||||
|
"name": "治理渠道",
|
||||||
|
"outbound_url": "https://channel.example/send",
|
||||||
|
"webhook_secret": "webhook-secret",
|
||||||
|
"outbound_token": "outbound-token",
|
||||||
|
},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["webhook_secret_set"] is True
|
||||||
|
assert payload["outbound_token_set"] is True
|
||||||
|
assert "webhook_secret" not in payload
|
||||||
|
assert "outbound_token" not in payload
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parents[3]
|
BASE_DIR = Path(__file__).resolve().parents[3]
|
||||||
|
load_dotenv(BASE_DIR / ".env", override=False)
|
||||||
|
|
||||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me")
|
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me")
|
||||||
DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true"
|
DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true"
|
||||||
@@ -13,17 +16,21 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
|
"django.contrib.postgres",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"corsheaders",
|
"corsheaders",
|
||||||
"apps.accounts",
|
"apps.accounts",
|
||||||
"apps.audit",
|
"apps.audit",
|
||||||
"apps.system",
|
"apps.system",
|
||||||
"apps.tickets",
|
"apps.tickets",
|
||||||
|
"apps.workitems",
|
||||||
"apps.solutions",
|
"apps.solutions",
|
||||||
"apps.knowledge",
|
"apps.knowledge",
|
||||||
"apps.modelhub",
|
"apps.modelhub",
|
||||||
"apps.skills",
|
"apps.skills",
|
||||||
"apps.agents",
|
"apps.agents",
|
||||||
|
"apps.tools",
|
||||||
|
"apps.channels",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -102,6 +109,8 @@ CSRF_TRUSTED_ORIGINS = os.getenv(
|
|||||||
"CSRF_TRUSTED_ORIGINS",
|
"CSRF_TRUSTED_ORIGINS",
|
||||||
"http://localhost:5173,http://127.0.0.1:5173",
|
"http://localhost:5173,http://127.0.0.1:5173",
|
||||||
).split(",")
|
).split(",")
|
||||||
|
FRONTEND_APP_URL = os.getenv("FRONTEND_APP_URL", "http://localhost:5173").rstrip("/")
|
||||||
|
OIDC_CALLBACK_BASE_URL = os.getenv("OIDC_CALLBACK_BASE_URL", "").rstrip("/")
|
||||||
|
|
||||||
DJANGO_SUPERUSER_USERNAME = os.getenv("DJANGO_SUPERUSER_USERNAME", "admin")
|
DJANGO_SUPERUSER_USERNAME = os.getenv("DJANGO_SUPERUSER_USERNAME", "admin")
|
||||||
DJANGO_SUPERUSER_PASSWORD = os.getenv("DJANGO_SUPERUSER_PASSWORD", "admin12345")
|
DJANGO_SUPERUSER_PASSWORD = os.getenv("DJANGO_SUPERUSER_PASSWORD", "admin12345")
|
||||||
@@ -111,8 +120,66 @@ CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
|||||||
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
|
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
|
||||||
|
|
||||||
AI_CREDENTIAL_ENCRYPTION_KEY = os.getenv("AI_CREDENTIAL_ENCRYPTION_KEY", "")
|
AI_CREDENTIAL_ENCRYPTION_KEY = os.getenv("AI_CREDENTIAL_ENCRYPTION_KEY", "")
|
||||||
|
INTERNAL_MCP_TOKEN = os.getenv("INTERNAL_MCP_TOKEN", "")
|
||||||
|
METRICS_TOKEN = os.getenv("METRICS_TOKEN", "")
|
||||||
AI_RUN_RETENTION_DAYS = int(os.getenv("AI_RUN_RETENTION_DAYS", "90"))
|
AI_RUN_RETENTION_DAYS = int(os.getenv("AI_RUN_RETENTION_DAYS", "90"))
|
||||||
|
AI_SLOW_RUN_THRESHOLD_SECONDS = int(os.getenv("AI_SLOW_RUN_THRESHOLD_SECONDS", "20"))
|
||||||
|
AI_AGENT_MODEL_TIMEOUT_SECONDS = int(os.getenv("AI_AGENT_MODEL_TIMEOUT_SECONDS", "20"))
|
||||||
|
KNOWLEDGE_EMBEDDING_DEPLOYMENT_ID = int(
|
||||||
|
os.getenv("KNOWLEDGE_EMBEDDING_DEPLOYMENT_ID", "0")
|
||||||
|
)
|
||||||
|
KNOWLEDGE_EMBEDDING_DIMENSIONS = int(os.getenv("KNOWLEDGE_EMBEDDING_DIMENSIONS", "1536"))
|
||||||
|
KNOWLEDGE_EMBEDDING_ALLOW_HASH_FALLBACK = (
|
||||||
|
os.getenv("KNOWLEDGE_EMBEDDING_ALLOW_HASH_FALLBACK", "false").lower() == "true"
|
||||||
|
)
|
||||||
|
KNOWLEDGE_RETRIEVAL_TEXT_WEIGHT = float(
|
||||||
|
os.getenv("KNOWLEDGE_RETRIEVAL_TEXT_WEIGHT", "0.45")
|
||||||
|
)
|
||||||
|
KNOWLEDGE_RETRIEVAL_VECTOR_WEIGHT = float(
|
||||||
|
os.getenv("KNOWLEDGE_RETRIEVAL_VECTOR_WEIGHT", "0.35")
|
||||||
|
)
|
||||||
|
KNOWLEDGE_RETRIEVAL_SCOPE_WEIGHT = float(
|
||||||
|
os.getenv("KNOWLEDGE_RETRIEVAL_SCOPE_WEIGHT", "0.20")
|
||||||
|
)
|
||||||
|
KNOWLEDGE_RERANK_DEPLOYMENT_ID = int(os.getenv("KNOWLEDGE_RERANK_DEPLOYMENT_ID", "0"))
|
||||||
|
KNOWLEDGE_RERANK_CANDIDATE_LIMIT = int(
|
||||||
|
os.getenv("KNOWLEDGE_RERANK_CANDIDATE_LIMIT", "30")
|
||||||
|
)
|
||||||
|
KNOWLEDGE_RERANK_MODEL_WEIGHT = float(os.getenv("KNOWLEDGE_RERANK_MODEL_WEIGHT", "0.60"))
|
||||||
|
TICKET_AUTO_RESOLVE_ENABLED = (
|
||||||
|
os.getenv("TICKET_AUTO_RESOLVE_ENABLED", "false").lower() == "true"
|
||||||
|
)
|
||||||
|
TICKET_AUTO_RESOLVE_MIN_SCORE = float(
|
||||||
|
os.getenv("TICKET_AUTO_RESOLVE_MIN_SCORE", "0.85")
|
||||||
|
)
|
||||||
|
WORK_ITEM_DEFAULT_SLA_HOURS = int(os.getenv("WORK_ITEM_DEFAULT_SLA_HOURS", "24"))
|
||||||
|
WORK_ITEM_INTAKE_FAILURE_SLA_HOURS = int(
|
||||||
|
os.getenv("WORK_ITEM_INTAKE_FAILURE_SLA_HOURS", "4")
|
||||||
|
)
|
||||||
|
WORK_ITEM_SUPPORT_FAILURE_SLA_HOURS = int(
|
||||||
|
os.getenv("WORK_ITEM_SUPPORT_FAILURE_SLA_HOURS", "4")
|
||||||
|
)
|
||||||
|
WORK_ITEM_CUSTOMER_REPLY_SLA_HOURS = int(
|
||||||
|
os.getenv("WORK_ITEM_CUSTOMER_REPLY_SLA_HOURS", "8")
|
||||||
|
)
|
||||||
|
WORK_ITEM_TICKET_CLOSE_SLA_HOURS = int(
|
||||||
|
os.getenv("WORK_ITEM_TICKET_CLOSE_SLA_HOURS", "24")
|
||||||
|
)
|
||||||
TICKET_DRAFT_RETENTION_HOURS = int(os.getenv("TICKET_DRAFT_RETENTION_HOURS", "24"))
|
TICKET_DRAFT_RETENTION_HOURS = int(os.getenv("TICKET_DRAFT_RETENTION_HOURS", "24"))
|
||||||
|
TICKET_MATERIAL_MAX_FILE_BYTES = int(
|
||||||
|
os.getenv("TICKET_MATERIAL_MAX_FILE_BYTES", str(20 * 1024 * 1024))
|
||||||
|
)
|
||||||
|
TICKET_MATERIAL_MAX_FILES = int(os.getenv("TICKET_MATERIAL_MAX_FILES", "10"))
|
||||||
|
TICKET_MATERIAL_MAX_TOTAL_BYTES = int(
|
||||||
|
os.getenv("TICKET_MATERIAL_MAX_TOTAL_BYTES", str(100 * 1024 * 1024))
|
||||||
|
)
|
||||||
|
TICKET_MATERIAL_MAX_PARSED_CHARS = int(
|
||||||
|
os.getenv("TICKET_MATERIAL_MAX_PARSED_CHARS", "200000")
|
||||||
|
)
|
||||||
|
TICKET_MATERIAL_SCANNER_BACKEND = os.getenv("TICKET_MATERIAL_SCANNER_BACKEND", "clamav")
|
||||||
|
CLAMAV_HOST = os.getenv("CLAMAV_HOST", "localhost")
|
||||||
|
CLAMAV_PORT = int(os.getenv("CLAMAV_PORT", "3310"))
|
||||||
|
CLAMAV_TIMEOUT_SECONDS = int(os.getenv("CLAMAV_TIMEOUT_SECONDS", "30"))
|
||||||
|
|
||||||
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
|
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
|
||||||
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", os.getenv("MINIO_ROOT_USER", "minio"))
|
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", os.getenv("MINIO_ROOT_USER", "minio"))
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
KNOWLEDGE_EMBEDDING_ALLOW_HASH_FALLBACK = True
|
||||||
|
|
||||||
PASSWORD_HASHERS = [
|
PASSWORD_HASHERS = [
|
||||||
"django.contrib.auth.hashers.MD5PasswordHasher",
|
"django.contrib.auth.hashers.MD5PasswordHasher",
|
||||||
]
|
]
|
||||||
@@ -14,3 +16,4 @@ PASSWORD_HASHERS = [
|
|||||||
AI_CREDENTIAL_ENCRYPTION_KEY = "t8XuQv9e8WDoFv7RXT4-zdw1hQChGFKtr7E6ccRZAzU="
|
AI_CREDENTIAL_ENCRYPTION_KEY = "t8XuQv9e8WDoFv7RXT4-zdw1hQChGFKtr7E6ccRZAzU="
|
||||||
CELERY_TASK_ALWAYS_EAGER = True
|
CELERY_TASK_ALWAYS_EAGER = True
|
||||||
CELERY_TASK_EAGER_PROPAGATES = True
|
CELERY_TASK_EAGER_PROPAGATES = True
|
||||||
|
TICKET_MATERIAL_SCANNER_BACKEND = "test"
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ from django.urls import path
|
|||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
|
|
||||||
from apps.accounts.api import router as accounts_router
|
from apps.accounts.api import router as accounts_router
|
||||||
|
from apps.accounts.identity_api import router as identity_router
|
||||||
from apps.agents.api import router as agents_router
|
from apps.agents.api import router as agents_router
|
||||||
from apps.agents.api import runs_router as agent_runs_router
|
from apps.agents.api import runs_router as agent_runs_router
|
||||||
from apps.audit.api import router as audit_router
|
from apps.audit.api import router as audit_router
|
||||||
|
from apps.channels.api import router as channels_router
|
||||||
from apps.knowledge.api import router as knowledge_router
|
from apps.knowledge.api import router as knowledge_router
|
||||||
from apps.modelhub.api import router as modelhub_router
|
from apps.modelhub.api import router as modelhub_router
|
||||||
from apps.skills.api import router as skills_router
|
from apps.skills.api import router as skills_router
|
||||||
@@ -13,21 +15,29 @@ from apps.solutions.api import router as solutions_router
|
|||||||
from apps.system.api import router as system_router
|
from apps.system.api import router as system_router
|
||||||
from apps.tickets.api import router as tickets_router
|
from apps.tickets.api import router as tickets_router
|
||||||
from apps.tickets.draft_api import router as ticket_drafts_router
|
from apps.tickets.draft_api import router as ticket_drafts_router
|
||||||
|
from apps.tools.api import router as tools_router
|
||||||
|
from apps.tools.mcp_gateway import internal_mcp_view
|
||||||
|
from apps.workitems.api import router as work_items_router
|
||||||
|
|
||||||
api = NinjaAPI(title="AI Ticket Platform API", version="0.1.0")
|
api = NinjaAPI(title="AI Ticket Platform API", version="0.1.0")
|
||||||
api.add_router("/accounts", accounts_router)
|
api.add_router("/accounts", accounts_router)
|
||||||
|
api.add_router("/identity", identity_router)
|
||||||
api.add_router("/system", system_router)
|
api.add_router("/system", system_router)
|
||||||
api.add_router("/audit", audit_router)
|
api.add_router("/audit", audit_router)
|
||||||
api.add_router("/tickets", tickets_router)
|
api.add_router("/tickets", tickets_router)
|
||||||
api.add_router("/ticket-drafts", ticket_drafts_router)
|
api.add_router("/ticket-drafts", ticket_drafts_router)
|
||||||
|
api.add_router("/tasks", work_items_router)
|
||||||
api.add_router("/solutions", solutions_router)
|
api.add_router("/solutions", solutions_router)
|
||||||
api.add_router("/knowledge", knowledge_router)
|
api.add_router("/knowledge", knowledge_router)
|
||||||
api.add_router("/modelhub", modelhub_router)
|
api.add_router("/modelhub", modelhub_router)
|
||||||
api.add_router("/skills", skills_router)
|
api.add_router("/skills", skills_router)
|
||||||
api.add_router("/agents", agents_router)
|
api.add_router("/agents", agents_router)
|
||||||
api.add_router("/agent-runs", agent_runs_router)
|
api.add_router("/agent-runs", agent_runs_router)
|
||||||
|
api.add_router("/tools", tools_router)
|
||||||
|
api.add_router("/channels", channels_router)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("internal-mcp", internal_mcp_view),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("api/", api.urls),
|
path("api/", api.urls),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const TicketDetailPage = React.lazy(() => import('@/features/tickets/TicketDetai
|
|||||||
const KnowledgeBaseListPage = React.lazy(() => import('@/features/knowledge/KnowledgeBaseListPage').then((module) => ({ default: module.KnowledgeBaseListPage })))
|
const KnowledgeBaseListPage = React.lazy(() => import('@/features/knowledge/KnowledgeBaseListPage').then((module) => ({ default: module.KnowledgeBaseListPage })))
|
||||||
const KnowledgeSearchPage = React.lazy(() => import('@/features/knowledge/KnowledgeSearchPage').then((module) => ({ default: module.KnowledgeSearchPage })))
|
const KnowledgeSearchPage = React.lazy(() => import('@/features/knowledge/KnowledgeSearchPage').then((module) => ({ default: module.KnowledgeSearchPage })))
|
||||||
const KnowledgeItemEditorPage = React.lazy(() => import('@/features/knowledge/KnowledgeItemEditorPage').then((module) => ({ default: module.KnowledgeItemEditorPage })))
|
const KnowledgeItemEditorPage = React.lazy(() => import('@/features/knowledge/KnowledgeItemEditorPage').then((module) => ({ default: module.KnowledgeItemEditorPage })))
|
||||||
|
const KnowledgeGovernancePage = React.lazy(() => import('@/features/knowledge/KnowledgeGovernancePage').then((module) => ({ default: module.KnowledgeGovernancePage })))
|
||||||
const SolutionListPage = React.lazy(() => import('@/features/solutions/SolutionListPage').then((module) => ({ default: module.SolutionListPage })))
|
const SolutionListPage = React.lazy(() => import('@/features/solutions/SolutionListPage').then((module) => ({ default: module.SolutionListPage })))
|
||||||
const SolutionCreatePage = React.lazy(() => import('@/features/solutions/SolutionCreatePage').then((module) => ({ default: module.SolutionCreatePage })))
|
const SolutionCreatePage = React.lazy(() => import('@/features/solutions/SolutionCreatePage').then((module) => ({ default: module.SolutionCreatePage })))
|
||||||
const ModelProviderPage = React.lazy(() => import('@/features/modelhub/ModelProviderPage').then((module) => ({ default: module.ModelProviderPage })))
|
const ModelProviderPage = React.lazy(() => import('@/features/modelhub/ModelProviderPage').then((module) => ({ default: module.ModelProviderPage })))
|
||||||
@@ -42,10 +43,13 @@ const AgentListPage = React.lazy(() => import('@/features/agents/AgentListPage')
|
|||||||
const AgentDetailPage = React.lazy(() => import('@/features/agents/AgentDetailPage').then((module) => ({ default: module.AgentDetailPage })))
|
const AgentDetailPage = React.lazy(() => import('@/features/agents/AgentDetailPage').then((module) => ({ default: module.AgentDetailPage })))
|
||||||
const AgentRunListPage = React.lazy(() => import('@/features/agents/AgentRunListPage').then((module) => ({ default: module.AgentRunListPage })))
|
const AgentRunListPage = React.lazy(() => import('@/features/agents/AgentRunListPage').then((module) => ({ default: module.AgentRunListPage })))
|
||||||
const AgentRunDetailPage = React.lazy(() => import('@/features/agents/AgentRunDetailPage').then((module) => ({ default: module.AgentRunDetailPage })))
|
const AgentRunDetailPage = React.lazy(() => import('@/features/agents/AgentRunDetailPage').then((module) => ({ default: module.AgentRunDetailPage })))
|
||||||
|
const ToolManagementPage = React.lazy(() => import('@/features/tools/ToolManagementPage').then((module) => ({ default: module.ToolManagementPage })))
|
||||||
|
const ChannelManagementPage = React.lazy(() => import('@/features/channels/ChannelManagementPage').then((module) => ({ default: module.ChannelManagementPage })))
|
||||||
const SystemUsersPage = React.lazy(() => import('@/features/system/SystemUsersPage').then((module) => ({ default: module.SystemUsersPage })))
|
const SystemUsersPage = React.lazy(() => import('@/features/system/SystemUsersPage').then((module) => ({ default: module.SystemUsersPage })))
|
||||||
const SystemUserGroupsPage = React.lazy(() => import('@/features/system/SystemUserGroupsPage').then((module) => ({ default: module.SystemUserGroupsPage })))
|
const SystemUserGroupsPage = React.lazy(() => import('@/features/system/SystemUserGroupsPage').then((module) => ({ default: module.SystemUserGroupsPage })))
|
||||||
const SystemRolesPage = React.lazy(() => import('@/features/system/SystemRolesPage').then((module) => ({ default: module.SystemRolesPage })))
|
const SystemRolesPage = React.lazy(() => import('@/features/system/SystemRolesPage').then((module) => ({ default: module.SystemRolesPage })))
|
||||||
const SystemMenusPage = React.lazy(() => import('@/features/system/SystemMenusPage').then((module) => ({ default: module.SystemMenusPage })))
|
const SystemMenusPage = React.lazy(() => import('@/features/system/SystemMenusPage').then((module) => ({ default: module.SystemMenusPage })))
|
||||||
|
const SystemIdentityPage = React.lazy(() => import('@/features/system/SystemIdentityPage').then((module) => ({ default: module.SystemIdentityPage })))
|
||||||
const AuditLogPage = React.lazy(() => import('@/features/system/AuditLogPage').then((module) => ({ default: module.AuditLogPage })))
|
const AuditLogPage = React.lazy(() => import('@/features/system/AuditLogPage').then((module) => ({ default: module.AuditLogPage })))
|
||||||
const DesignSystemPreview = React.lazy(() => import('@/features/design-system/DesignSystemPreview').then((module) => ({ default: module.DesignSystemPreview })))
|
const DesignSystemPreview = React.lazy(() => import('@/features/design-system/DesignSystemPreview').then((module) => ({ default: module.DesignSystemPreview })))
|
||||||
const ProfilePage = React.lazy(() => import('@/features/profile/ProfilePage').then((module) => ({ default: module.ProfilePage })))
|
const ProfilePage = React.lazy(() => import('@/features/profile/ProfilePage').then((module) => ({ default: module.ProfilePage })))
|
||||||
@@ -53,11 +57,12 @@ const ProfilePage = React.lazy(() => import('@/features/profile/ProfilePage').th
|
|||||||
export const routeMeta: RouteMeta[] = [
|
export const routeMeta: RouteMeta[] = [
|
||||||
{ key: 'workbench', pattern: /^\/$/, title: '工作台', description: '聚合待办、风险与最近处理进展', section: '工作台', navPath: '/', breadcrumbs: [], agent: 'docked', render: () => <WorkbenchPage /> },
|
{ key: 'workbench', pattern: /^\/$/, title: '工作台', description: '聚合待办、风险与最近处理进展', section: '工作台', navPath: '/', breadcrumbs: [], agent: 'docked', render: () => <WorkbenchPage /> },
|
||||||
{ key: 'profile', pattern: /^\/profile$/, title: '个人中心', description: '查看个人工作情况并维护账号设置', section: '个人中心', navPath: '/profile', breadcrumbs: [{ label: '显示与账户' }], agent: 'on-demand', render: () => <ProfilePage /> },
|
{ key: 'profile', pattern: /^\/profile$/, title: '个人中心', description: '查看个人工作情况并维护账号设置', section: '个人中心', navPath: '/profile', breadcrumbs: [{ label: '显示与账户' }], agent: 'on-demand', render: () => <ProfilePage /> },
|
||||||
{ key: 'ticket-create', pattern: /^\/tickets\/new$/, title: '创建工单', description: '记录问题上下文并进入支持流程', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], agent: 'docked', render: () => <TicketCreatePage /> },
|
{ key: 'ticket-create', pattern: /^\/tickets\/new(?:\?.*)?$/, title: '创建工单', description: '记录问题上下文并进入支持流程', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], agent: 'docked', render: () => <TicketCreatePage /> },
|
||||||
{ key: 'ticket-detail', pattern: /^\/tickets\/(\d+)$/, title: '工单详情', description: '处理状态、方案与协作记录', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], backPath: '/tickets', agent: 'docked', render: (match) => <TicketDetailPage ticketId={Number(match[1])} /> },
|
{ key: 'ticket-detail', pattern: /^\/tickets\/(\d+)$/, title: '工单详情', description: '处理状态、方案与协作记录', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], backPath: '/tickets', agent: 'docked', render: (match) => <TicketDetailPage ticketId={Number(match[1])} /> },
|
||||||
{ key: 'tickets', pattern: /^\/tickets(?:\?.*)?$/, title: '工单中心', description: '统一查看、筛选和推进支持工单', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单' }], agent: 'docked', render: () => <TicketListPage /> },
|
{ key: 'tickets', pattern: /^\/tickets(?:\?.*)?$/, title: '工单中心', description: '统一查看、筛选和推进支持工单', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单' }], agent: 'docked', render: () => <TicketListPage /> },
|
||||||
{ key: 'knowledge-search', pattern: /^\/knowledge\/search$/, title: '知识检索', description: '跨知识条目与解决方案检索处理依据', section: '知识与方案', navPath: '/knowledge/search', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <KnowledgeSearchPage /> },
|
{ key: 'knowledge-search', pattern: /^\/knowledge\/search$/, title: '知识检索', description: '跨知识条目与解决方案检索处理依据', section: '知识与方案', navPath: '/knowledge/search', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <KnowledgeSearchPage /> },
|
||||||
{ key: 'knowledge-create', pattern: /^\/knowledge\/items\/new$/, title: '新建知识条目', description: '沉淀可复用的支持知识', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => <KnowledgeItemEditorPage /> },
|
{ key: 'knowledge-create', pattern: /^\/knowledge\/items\/new$/, title: '新建知识条目', description: '沉淀可复用的支持知识', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => <KnowledgeItemEditorPage /> },
|
||||||
|
{ key: 'knowledge-governance', pattern: /^\/knowledge\/governance$/, title: '知识治理', description: '审核资产、追踪版本与查看影响范围', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => <KnowledgeGovernancePage /> },
|
||||||
{ key: 'knowledge', pattern: /^\/knowledge$/, title: '知识库', description: '集中管理业务知识、文档与检索范围', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <KnowledgeBaseListPage /> },
|
{ key: 'knowledge', pattern: /^\/knowledge$/, title: '知识库', description: '集中管理业务知识、文档与检索范围', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <KnowledgeBaseListPage /> },
|
||||||
{ key: 'solution-create', pattern: /^\/solutions\/new(?:\?.*)?$/, title: '新建解决方案', description: '将验证过的处理过程沉淀为方案', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '解决方案库', path: '/solutions' }], backPath: '/solutions', agent: 'docked', render: () => <SolutionCreatePage /> },
|
{ key: 'solution-create', pattern: /^\/solutions\/new(?:\?.*)?$/, title: '新建解决方案', description: '将验证过的处理过程沉淀为方案', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '解决方案库', path: '/solutions' }], backPath: '/solutions', agent: 'docked', render: () => <SolutionCreatePage /> },
|
||||||
{ key: 'solutions', pattern: /^\/solutions$/, title: '解决方案库', description: '复用已验证的处置流程和回复依据', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <SolutionListPage /> },
|
{ key: 'solutions', pattern: /^\/solutions$/, title: '解决方案库', description: '复用已验证的处置流程和回复依据', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <SolutionListPage /> },
|
||||||
@@ -68,10 +73,13 @@ export const routeMeta: RouteMeta[] = [
|
|||||||
{ key: 'agents', pattern: /^\/agents$/, title: 'Agent 管理', description: '管理面向支持场景的智能助手', section: 'AI 平台', navPath: '/agents', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <AgentListPage /> },
|
{ key: 'agents', pattern: /^\/agents$/, title: 'Agent 管理', description: '管理面向支持场景的智能助手', section: 'AI 平台', navPath: '/agents', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <AgentListPage /> },
|
||||||
{ key: 'agent-run-detail', pattern: /^\/agent-runs\/([0-9a-f-]+)$/i, title: '运行记录详情', description: '查看一次 Agent 运行的输入、输出与状态', section: 'AI 平台', navPath: '/agent-runs', breadcrumbs: [{ label: 'Agent 运行记录', path: '/agent-runs' }], backPath: '/agent-runs', agent: 'on-demand', render: (match) => <AgentRunDetailPage publicId={match[1]} /> },
|
{ key: 'agent-run-detail', pattern: /^\/agent-runs\/([0-9a-f-]+)$/i, title: '运行记录详情', description: '查看一次 Agent 运行的输入、输出与状态', section: 'AI 平台', navPath: '/agent-runs', breadcrumbs: [{ label: 'Agent 运行记录', path: '/agent-runs' }], backPath: '/agent-runs', agent: 'on-demand', render: (match) => <AgentRunDetailPage publicId={match[1]} /> },
|
||||||
{ key: 'agent-runs', pattern: /^\/agent-runs$/, title: 'Agent 运行记录', description: '追踪智能处理任务的执行结果', section: 'AI 平台', navPath: '/agent-runs', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <AgentRunListPage /> },
|
{ key: 'agent-runs', pattern: /^\/agent-runs$/, title: 'Agent 运行记录', description: '追踪智能处理任务的执行结果', section: 'AI 平台', navPath: '/agent-runs', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <AgentRunListPage /> },
|
||||||
|
{ key: 'tools', pattern: /^\/tools$/, title: '工具治理', description: '统一管理 Tool、MCP、授权和调用风险', section: 'AI 平台', navPath: '/tools', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <ToolManagementPage /> },
|
||||||
|
{ key: 'channels', pattern: /^\/channels$/, title: '渠道接入', description: '管理服务群消息的幂等收发与回执', section: 'AI 平台', navPath: '/channels', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <ChannelManagementPage /> },
|
||||||
{ key: 'system-users', pattern: /^\/system\/users$/, title: '用户管理', description: '维护平台账号与可用状态', section: '系统治理', navPath: '/system/users', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemUsersPage /> },
|
{ key: 'system-users', pattern: /^\/system\/users$/, title: '用户管理', description: '维护平台账号与可用状态', section: '系统治理', navPath: '/system/users', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemUsersPage /> },
|
||||||
{ key: 'system-groups', pattern: /^\/system\/user-groups$/, title: '用户组管理', description: '按组织和职责管理授权范围', section: '系统治理', navPath: '/system/user-groups', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemUserGroupsPage /> },
|
{ key: 'system-groups', pattern: /^\/system\/user-groups$/, title: '用户组管理', description: '按组织和职责管理授权范围', section: '系统治理', navPath: '/system/user-groups', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemUserGroupsPage /> },
|
||||||
{ key: 'system-roles', pattern: /^\/system\/roles$/, title: '角色管理', description: '维护角色与权限集合', section: '系统治理', navPath: '/system/roles', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemRolesPage /> },
|
{ key: 'system-roles', pattern: /^\/system\/roles$/, title: '角色管理', description: '维护角色与权限集合', section: '系统治理', navPath: '/system/roles', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemRolesPage /> },
|
||||||
{ key: 'system-menus', pattern: /^\/system\/menus$/, title: '菜单权限', description: '管理导航可见性和功能权限', section: '系统治理', navPath: '/system/menus', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemMenusPage /> },
|
{ key: 'system-menus', pattern: /^\/system\/menus$/, title: '菜单权限', description: '管理导航可见性和功能权限', section: '系统治理', navPath: '/system/menus', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemMenusPage /> },
|
||||||
|
{ key: 'system-identity', pattern: /^\/system\/identity$/, title: '企业身份', description: '管理单点登录、外部身份与组织同步', section: '系统治理', navPath: '/system/identity', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemIdentityPage /> },
|
||||||
{ key: 'audit', pattern: /^\/audit$/, title: '审计日志', description: '追踪关键配置与业务操作记录', section: '系统治理', navPath: '/audit', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <AuditLogPage /> },
|
{ key: 'audit', pattern: /^\/audit$/, title: '审计日志', description: '追踪关键配置与业务操作记录', section: '系统治理', navPath: '/audit', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <AuditLogPage /> },
|
||||||
{ key: 'design-system', pattern: /^\/design-system$/, title: '设计系统预览', description: '检查基础组件和语义状态', section: '系统治理', navPath: '/design-system', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <DesignSystemPreview /> },
|
{ key: 'design-system', pattern: /^\/design-system$/, title: '设计系统预览', description: '检查基础组件和语义状态', section: '系统治理', navPath: '/design-system', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <DesignSystemPreview /> },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type AppCreateDialogProps = {
|
|||||||
onSubmit: React.FormEventHandler<HTMLFormElement>
|
onSubmit: React.FormEventHandler<HTMLFormElement>
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
pending?: boolean
|
pending?: boolean
|
||||||
|
pendingLabel?: string
|
||||||
submitDisabled?: boolean
|
submitDisabled?: boolean
|
||||||
error?: string
|
error?: string
|
||||||
className?: string
|
className?: string
|
||||||
@@ -37,6 +38,7 @@ export function AppCreateDialog({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
children,
|
children,
|
||||||
pending = false,
|
pending = false,
|
||||||
|
pendingLabel = '正在创建…',
|
||||||
submitDisabled = false,
|
submitDisabled = false,
|
||||||
error,
|
error,
|
||||||
className,
|
className,
|
||||||
@@ -69,7 +71,7 @@ export function AppCreateDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={pending || submitDisabled}>
|
<Button type="submit" disabled={pending || submitDisabled}>
|
||||||
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" /> : <Plus data-icon="inline-start" />}
|
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" /> : <Plus data-icon="inline-start" />}
|
||||||
{pending ? '正在创建…' : submitLabel}
|
{pending ? pendingLabel : submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -12,11 +12,19 @@ function navigate(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('ResizeObserver', class ResizeObserverMock {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
})
|
||||||
window.sessionStorage.clear()
|
window.sessionStorage.clear()
|
||||||
window.location.hash = '#/tickets/42'
|
window.location.hash = '#/tickets/42'
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => cleanup())
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
describe('已打开页面标签', () => {
|
describe('已打开页面标签', () => {
|
||||||
it('只显示实际打开过的页面,并允许关闭非当前标签', async () => {
|
it('只显示实际打开过的页面,并允许关闭非当前标签', async () => {
|
||||||
@@ -64,6 +72,47 @@ describe('已打开页面标签', () => {
|
|||||||
expect(screen.getByRole('tab', { name: '知识库' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: '知识库' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('右键标签可以关闭其左侧和右侧标签', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<AppRouteTabs />)
|
||||||
|
navigate('/knowledge')
|
||||||
|
navigate('/solutions')
|
||||||
|
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(3))
|
||||||
|
|
||||||
|
fireEvent.contextMenu(screen.getByRole('tab', { name: '知识库' }))
|
||||||
|
await user.click(await screen.findByRole('menuitem', { name: '关闭左侧标签' }))
|
||||||
|
|
||||||
|
expect(screen.queryByRole('tab', { name: '工单详情' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: '知识库' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: '解决方案库' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.contextMenu(screen.getByRole('tab', { name: '知识库' }))
|
||||||
|
await user.click(await screen.findByRole('menuitem', { name: '关闭右侧标签' }))
|
||||||
|
|
||||||
|
expect(screen.queryByRole('tab', { name: '解决方案库' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.getAllByRole('tab')).toHaveLength(1)
|
||||||
|
expect(window.location.hash).toBe('#/knowledge')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('批量菜单可以关闭其他标签和关闭全部标签', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<AppRouteTabs />)
|
||||||
|
navigate('/knowledge')
|
||||||
|
navigate('/solutions')
|
||||||
|
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(3))
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '查看全部已打开页面,共 3 个' }))
|
||||||
|
await user.click(screen.getByRole('menuitem', { name: '关闭其他标签' }))
|
||||||
|
expect(screen.getAllByRole('tab')).toHaveLength(1)
|
||||||
|
expect(screen.getByRole('tab', { name: '解决方案库' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '查看全部已打开页面,共 1 个' }))
|
||||||
|
await user.click(screen.getByRole('menuitem', { name: '关闭全部标签' }))
|
||||||
|
expect(screen.getAllByRole('tab')).toHaveLength(1)
|
||||||
|
expect(screen.getByRole('tab', { name: '工作台' })).toBeInTheDocument()
|
||||||
|
expect(window.location.hash).toBe('#/')
|
||||||
|
})
|
||||||
|
|
||||||
it('标签溢出时隐藏原生滚动条,并提供左右切换和全部页面菜单', async () => {
|
it('标签溢出时隐藏原生滚动条,并提供左右切换和全部页面菜单', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const { container } = render(<AppRouteTabs />)
|
const { container } = render(<AppRouteTabs />)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { Check, ChevronLeft, ChevronRight, Ellipsis, X } from 'lucide-react'
|
import { Check, ChevronLeft, ChevronRight, Ellipsis, Layers2, PanelLeftClose, PanelRightClose, Trash2, X } from 'lucide-react'
|
||||||
|
|
||||||
import { useCurrentRoute, type RouteNavigationTab } from '@/app/route-meta'
|
import { useCurrentRoute, type RouteNavigationTab } from '@/app/route-meta'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -9,14 +9,26 @@ import {
|
|||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuShortcut,
|
DropdownMenuShortcut,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger
|
||||||
|
} from '@/components/ui/context-menu'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
||||||
const STORAGE_KEY = 'ai-ticket-platform.opened-route-tabs.v2'
|
const STORAGE_KEY = 'ai-ticket-platform.opened-route-tabs.v2'
|
||||||
const MAX_OPEN_TABS = 12
|
const MAX_OPEN_TABS = 12
|
||||||
const SCROLL_EPSILON = 2
|
const SCROLL_EPSILON = 2
|
||||||
|
const WORKBENCH_TAB: RouteNavigationTab = { label: '工作台', path: '/' }
|
||||||
|
|
||||||
|
type CloseScope = 'left' | 'right' | 'others' | 'all'
|
||||||
|
|
||||||
type TabScrollState = {
|
type TabScrollState = {
|
||||||
overflow: boolean
|
overflow: boolean
|
||||||
@@ -60,6 +72,7 @@ export function AppRouteTabs() {
|
|||||||
canScrollLeft: false,
|
canScrollLeft: false,
|
||||||
canScrollRight: false
|
canScrollRight: false
|
||||||
})
|
})
|
||||||
|
const activeTabIndex = openedTabs.findIndex((tab) => tab.path === route)
|
||||||
|
|
||||||
const syncScrollState = React.useCallback(() => {
|
const syncScrollState = React.useCallback(() => {
|
||||||
const element = scrollAreaRef.current
|
const element = scrollAreaRef.current
|
||||||
@@ -124,13 +137,33 @@ export function AppRouteTabs() {
|
|||||||
|
|
||||||
const closeTab = (path: string) => {
|
const closeTab = (path: string) => {
|
||||||
const closedIndex = openedTabs.findIndex((tab) => tab.path === path)
|
const closedIndex = openedTabs.findIndex((tab) => tab.path === path)
|
||||||
const next = openedTabs.filter((tab) => tab.path !== path)
|
const remaining = openedTabs.filter((tab) => tab.path !== path)
|
||||||
|
const next = remaining.length ? remaining : [WORKBENCH_TAB]
|
||||||
saveOpenedTabs(next)
|
saveOpenedTabs(next)
|
||||||
setOpenedTabs(next)
|
setOpenedTabs(next)
|
||||||
|
|
||||||
if (path !== route) return
|
if (path !== route) return
|
||||||
const fallback = next[Math.max(0, closedIndex - 1)] ?? next[0]
|
const fallback = remaining[Math.max(0, closedIndex - 1)] ?? remaining[0] ?? WORKBENCH_TAB
|
||||||
window.location.hash = fallback?.path ?? '/'
|
window.location.hash = fallback.path
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeTabs = (anchorPath: string, scope: CloseScope) => {
|
||||||
|
const anchorIndex = openedTabs.findIndex((tab) => tab.path === anchorPath)
|
||||||
|
if (anchorIndex < 0 && scope !== 'all') return
|
||||||
|
|
||||||
|
let remaining: RouteNavigationTab[]
|
||||||
|
if (scope === 'left') remaining = openedTabs.slice(anchorIndex)
|
||||||
|
else if (scope === 'right') remaining = openedTabs.slice(0, anchorIndex + 1)
|
||||||
|
else if (scope === 'others') remaining = [openedTabs[anchorIndex]]
|
||||||
|
else remaining = []
|
||||||
|
|
||||||
|
const next = remaining.length ? remaining : [WORKBENCH_TAB]
|
||||||
|
saveOpenedTabs(next)
|
||||||
|
setOpenedTabs(next)
|
||||||
|
|
||||||
|
if (next.some((tab) => tab.path === route)) return
|
||||||
|
const anchor = next.find((tab) => tab.path === anchorPath)
|
||||||
|
window.location.hash = anchor?.path ?? WORKBENCH_TAB.path
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollTabs = (direction: -1 | 1) => {
|
const scrollTabs = (direction: -1 | 1) => {
|
||||||
@@ -175,9 +208,10 @@ export function AppRouteTabs() {
|
|||||||
event.currentTarget.scrollLeft += event.deltaY
|
event.currentTarget.scrollLeft += event.deltaY
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{openedTabs.map((tab) => (
|
{openedTabs.map((tab, tabIndex) => (
|
||||||
|
<ContextMenu key={tab.path}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<div
|
||||||
key={tab.path}
|
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
if (node) tabRefs.current.set(tab.path, node)
|
if (node) tabRefs.current.set(tab.path, node)
|
||||||
else tabRefs.current.delete(tab.path)
|
else tabRefs.current.delete(tab.path)
|
||||||
@@ -203,11 +237,24 @@ export function AppRouteTabs() {
|
|||||||
<X />
|
<X />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="w-44">
|
||||||
|
<ContextMenuGroup>
|
||||||
|
<ContextMenuItem onSelect={() => closeTab(tab.path)}><X />关闭当前标签</ContextMenuItem>
|
||||||
|
<ContextMenuItem disabled={tabIndex === 0} onSelect={() => closeTabs(tab.path, 'left')}><PanelLeftClose />关闭左侧标签</ContextMenuItem>
|
||||||
|
<ContextMenuItem disabled={tabIndex === openedTabs.length - 1} onSelect={() => closeTabs(tab.path, 'right')}><PanelRightClose />关闭右侧标签</ContextMenuItem>
|
||||||
|
<ContextMenuItem disabled={openedTabs.length <= 1} onSelect={() => closeTabs(tab.path, 'others')}><Layers2 />关闭其他标签</ContextMenuItem>
|
||||||
|
</ContextMenuGroup>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuGroup>
|
||||||
|
<ContextMenuItem onSelect={() => closeTabs(tab.path, 'all')}><Trash2 />关闭全部标签</ContextMenuItem>
|
||||||
|
</ContextMenuGroup>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
{scrollState.overflow ? (
|
{scrollState.overflow ? (
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -218,15 +265,16 @@ export function AppRouteTabs() {
|
|||||||
>
|
>
|
||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button type="button" variant="ghost" size="icon-sm" aria-label={`查看全部已打开页面,共 ${openedTabs.length} 个`}>
|
<Button type="button" variant="ghost" size="icon-sm" aria-label={`查看全部已打开页面,共 ${openedTabs.length} 个`} title="管理已打开页面">
|
||||||
<Ellipsis />
|
<Ellipsis />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="scrollbar-panel max-h-80 w-64">
|
<DropdownMenuContent align="end" className="scrollbar-panel max-h-80 w-64">
|
||||||
<DropdownMenuLabel>已打开页面 · {openedTabs.length}</DropdownMenuLabel>
|
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel>已打开页面 · {openedTabs.length}</DropdownMenuLabel>
|
||||||
{openedTabs.map((tab) => (
|
{openedTabs.map((tab) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={tab.path}
|
key={tab.path}
|
||||||
@@ -241,10 +289,16 @@ export function AppRouteTabs() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel>批量关闭</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem disabled={activeTabIndex <= 0} onSelect={() => closeTabs(route, 'left')}><PanelLeftClose />关闭左侧标签</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem disabled={activeTabIndex < 0 || activeTabIndex === openedTabs.length - 1} onSelect={() => closeTabs(route, 'right')}><PanelRightClose />关闭右侧标签</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem disabled={openedTabs.length <= 1} onSelect={() => closeTabs(route, 'others')}><Layers2 />关闭其他标签</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => closeTabs(route, 'all')}><Trash2 />关闭全部标签</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Library,
|
Library,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
|
MessageSquareMore,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -56,6 +57,7 @@ const iconMap = {
|
|||||||
home: Home,
|
home: Home,
|
||||||
library: Library,
|
library: Library,
|
||||||
menu: Menu,
|
menu: Menu,
|
||||||
|
message: MessageSquareMore,
|
||||||
roles: UserCog,
|
roles: UserCog,
|
||||||
search: Search,
|
search: Search,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
|||||||
55
frontend/src/features/auth/LoginPage.test.tsx
Normal file
55
frontend/src/features/auth/LoginPage.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { LoginPage } from './LoginPage'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
login: vi.fn(),
|
||||||
|
listIdentityLoginProviders: vi.fn(),
|
||||||
|
startIdentityLogin: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./AuthProvider', () => ({
|
||||||
|
useAuth: () => ({ login: mocks.login })
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./api', () => ({
|
||||||
|
listIdentityLoginProviders: mocks.listIdentityLoginProviders,
|
||||||
|
startIdentityLogin: mocks.startIdentityLogin
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('LoginPage enterprise identity', () => {
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
window.location.hash = ''
|
||||||
|
mocks.listIdentityLoginProviders.mockResolvedValue([
|
||||||
|
{
|
||||||
|
slug: 'corp',
|
||||||
|
name: '企业统一身份',
|
||||||
|
button_label: '使用企业账号登录',
|
||||||
|
provider_type: 'oidc'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows enabled OIDC providers and starts enterprise login', async () => {
|
||||||
|
render(<LoginPage />)
|
||||||
|
|
||||||
|
const ssoButton = await screen.findByRole('button', { name: '使用企业账号登录' })
|
||||||
|
fireEvent.click(ssoButton)
|
||||||
|
|
||||||
|
expect(mocks.startIdentityLogin).toHaveBeenCalledWith('corp')
|
||||||
|
expect(screen.getByText('或使用本地账号')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces callback error without hiding local login', async () => {
|
||||||
|
window.location.hash = '#/login?auth_error=企业登录失败'
|
||||||
|
render(<LoginPage />)
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('企业登录失败')).toBeInTheDocument())
|
||||||
|
expect(screen.getByPlaceholderText('请输入账号')).toBeInTheDocument()
|
||||||
|
expect(screen.getByPlaceholderText('请输入密码')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { AlertCircle, CheckCircle2, LockKeyhole, Sparkles, UserRound, Workflow } from 'lucide-react'
|
import { AlertCircle, Building2, CheckCircle2, LockKeyhole, Sparkles, UserRound, Workflow } from 'lucide-react'
|
||||||
|
|
||||||
import { AppField } from '@/components/app/AppField'
|
import { AppField } from '@/components/app/AppField'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { FieldSeparator } from '@/components/ui/field'
|
||||||
|
|
||||||
import { useAuth } from './AuthProvider'
|
import { useAuth } from './AuthProvider'
|
||||||
|
import { listIdentityLoginProviders, startIdentityLogin, type IdentityLoginProvider } from './api'
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
@@ -15,6 +17,14 @@ export function LoginPage() {
|
|||||||
const [password, setPassword] = React.useState('')
|
const [password, setPassword] = React.useState('')
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
const [submitting, setSubmitting] = React.useState(false)
|
const [submitting, setSubmitting] = React.useState(false)
|
||||||
|
const [providers, setProviders] = React.useState<IdentityLoginProvider[]>([])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void listIdentityLoginProviders().then(setProviders).catch(() => setProviders([]))
|
||||||
|
const query = window.location.hash.split('?')[1]
|
||||||
|
const authError = query ? new URLSearchParams(query).get('auth_error') : ''
|
||||||
|
if (authError) setError(authError)
|
||||||
|
}, [])
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -43,6 +53,24 @@ export function LoginPage() {
|
|||||||
<CardContent className="px-6">
|
<CardContent className="px-6">
|
||||||
<form className="grid gap-4" onSubmit={handleSubmit}>
|
<form className="grid gap-4" onSubmit={handleSubmit}>
|
||||||
{error ? <Alert variant="destructive"><AlertCircle className="size-4" /><AlertTitle>无法登录</AlertTitle><AlertDescription>{error}</AlertDescription></Alert> : null}
|
{error ? <Alert variant="destructive"><AlertCircle className="size-4" /><AlertTitle>无法登录</AlertTitle><AlertDescription>{error}</AlertDescription></Alert> : null}
|
||||||
|
{providers.length ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<Button
|
||||||
|
key={provider.slug}
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => startIdentityLogin(provider.slug)}
|
||||||
|
>
|
||||||
|
<Building2 data-icon="inline-start" />
|
||||||
|
{provider.button_label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
<FieldSeparator>或使用本地账号</FieldSeparator>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<AppField label="账号" htmlFor="username" invalid={Boolean(error)} required><div className="relative"><UserRound className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input id="username" autoComplete="username" aria-invalid={Boolean(error)} className="pl-9" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="请输入账号" /></div></AppField>
|
<AppField label="账号" htmlFor="username" invalid={Boolean(error)} required><div className="relative"><UserRound className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input id="username" autoComplete="username" aria-invalid={Boolean(error)} className="pl-9" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="请输入账号" /></div></AppField>
|
||||||
<AppField label="密码" htmlFor="password" invalid={Boolean(error)} required><div className="relative"><LockKeyhole className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input id="password" type="password" autoComplete="current-password" aria-invalid={Boolean(error)} className="pl-9" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" /></div></AppField>
|
<AppField label="密码" htmlFor="password" invalid={Boolean(error)} required><div className="relative"><LockKeyhole className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input id="password" type="password" autoComplete="current-password" aria-invalid={Boolean(error)} className="pl-9" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" /></div></AppField>
|
||||||
<Button type="submit" size="lg" className="mt-1 w-full" disabled={submitting || !username || !password}>{submitting ? '登录中...' : '登录'}</Button>
|
<Button type="submit" size="lg" className="mt-1 w-full" disabled={submitting || !username || !password}>{submitting ? '登录中...' : '登录'}</Button>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { apiRequest } from '@/lib/api'
|
import { apiRequest, apiUrl } from '@/lib/api'
|
||||||
|
|
||||||
import type { CurrentUser } from './types'
|
import type { CurrentUser } from './types'
|
||||||
|
|
||||||
|
export type IdentityLoginProvider = {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
button_label: string
|
||||||
|
provider_type: 'oidc'
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCurrentUser() {
|
export async function getCurrentUser() {
|
||||||
return apiRequest<CurrentUser>('/accounts/auth/me')
|
return apiRequest<CurrentUser>('/accounts/auth/me')
|
||||||
}
|
}
|
||||||
@@ -32,3 +39,12 @@ export async function logout() {
|
|||||||
method: 'POST'
|
method: 'POST'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listIdentityLoginProviders() {
|
||||||
|
return apiRequest<IdentityLoginProvider[]>('/identity/auth/providers')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startIdentityLogin(slug: string, returnTo = '/') {
|
||||||
|
const query = new URLSearchParams({ return_to: returnTo })
|
||||||
|
window.location.assign(apiUrl(`/identity/auth/oidc/${encodeURIComponent(slug)}/start?${query}`))
|
||||||
|
}
|
||||||
|
|||||||
109
frontend/src/features/channels/ChannelManagementPage.test.tsx
Normal file
109
frontend/src/features/channels/ChannelManagementPage.test.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { ChannelManagementPage } from './ChannelManagementPage'
|
||||||
|
import {
|
||||||
|
listChannelEndpoints,
|
||||||
|
listChannelMessages,
|
||||||
|
retryChannelMessage
|
||||||
|
} from './api'
|
||||||
|
|
||||||
|
vi.mock('@/features/auth/AuthProvider', () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
user: {
|
||||||
|
is_superuser: true,
|
||||||
|
permissions: ['channel.view', 'channel.send', 'channel.manage']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./api', () => ({
|
||||||
|
listChannelEndpoints: vi.fn(),
|
||||||
|
listChannelMessages: vi.fn(),
|
||||||
|
createChannelEndpoint: vi.fn(),
|
||||||
|
updateChannelEndpoint: vi.fn(),
|
||||||
|
sendChannelMessage: vi.fn(),
|
||||||
|
retryChannelMessage: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const endpoint = {
|
||||||
|
id: 1,
|
||||||
|
public_id: 'endpoint-public-id',
|
||||||
|
name: '一线服务群',
|
||||||
|
adapter_type: 'generic_webhook' as const,
|
||||||
|
outbound_url: 'https://channel.example/messages',
|
||||||
|
webhook_secret_set: true,
|
||||||
|
outbound_token_set: true,
|
||||||
|
enabled: true,
|
||||||
|
created_at: '2026-07-27T08:00:00Z',
|
||||||
|
updated_at: '2026-07-27T08:00:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedMessage = {
|
||||||
|
public_id: 'message-public-id',
|
||||||
|
endpoint_id: 1,
|
||||||
|
endpoint_name: '一线服务群',
|
||||||
|
conversation_public_id: 'conversation-public-id',
|
||||||
|
external_conversation_id: 'group-001',
|
||||||
|
ticket_id: 42,
|
||||||
|
customer_reply_task_id: 15,
|
||||||
|
reply_revision: 2,
|
||||||
|
direction: 'outbound' as const,
|
||||||
|
external_message_id: '',
|
||||||
|
idempotency_key: 'reply-001',
|
||||||
|
sender: '支持工程师',
|
||||||
|
message_type: 'text',
|
||||||
|
text: '正在处理中,请稍候。',
|
||||||
|
payload: {},
|
||||||
|
status: 'failed' as const,
|
||||||
|
error_message: '上游连接超时',
|
||||||
|
occurred_at: null,
|
||||||
|
sent_at: null,
|
||||||
|
delivered_at: null,
|
||||||
|
created_at: '2026-07-27T08:10:00Z',
|
||||||
|
updated_at: '2026-07-27T08:10:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChannelManagementPage', () => {
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(listChannelEndpoints).mockResolvedValue([endpoint])
|
||||||
|
vi.mocked(listChannelMessages).mockResolvedValue([failedMessage])
|
||||||
|
vi.mocked(retryChannelMessage).mockResolvedValue({
|
||||||
|
...failedMessage,
|
||||||
|
status: 'queued',
|
||||||
|
error_message: ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('展示渠道消息并允许失败出站重试', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ChannelManagementPage />)
|
||||||
|
|
||||||
|
expect(await screen.findByText('正在处理中,请稍候。')).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('一线服务群').length).toBeGreaterThan(0)
|
||||||
|
expect(screen.getByText('上游连接超时')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/回复任务 #15 v2/)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: '发送消息' })).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '重试' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(retryChannelMessage).toHaveBeenCalledWith('message-public-id')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('展示适配器凭证状态和 Webhook 地址', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<ChannelManagementPage />)
|
||||||
|
|
||||||
|
await screen.findByText('正在处理中,请稍候。')
|
||||||
|
await user.click(screen.getByRole('tab', { name: '适配器' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('签名密钥:已配置')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('出站凭证:已配置')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/endpoint-public-id/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
414
frontend/src/features/channels/ChannelManagementPage.tsx
Normal file
414
frontend/src/features/channels/ChannelManagementPage.tsx
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowUpFromLine,
|
||||||
|
CircleAlert,
|
||||||
|
Copy,
|
||||||
|
KeyRound,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
Webhook
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { AppCreateDialog } from '@/components/app/AppCreateDialog'
|
||||||
|
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
|
||||||
|
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||||
|
import { AppField } from '@/components/app/AppField'
|
||||||
|
import { AppPage } from '@/components/app/AppPage'
|
||||||
|
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||||
|
import { AppSection } from '@/components/app/AppSection'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { useAuth } from '@/features/auth/AuthProvider'
|
||||||
|
import { formatDateTime } from '@/lib/format'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createChannelEndpoint,
|
||||||
|
listChannelEndpoints,
|
||||||
|
listChannelMessages,
|
||||||
|
retryChannelMessage,
|
||||||
|
updateChannelEndpoint
|
||||||
|
} from './api'
|
||||||
|
import type { ChannelEndpoint, ChannelMessage, ChannelMessageStatus } from './types'
|
||||||
|
|
||||||
|
const endpointInitial = {
|
||||||
|
name: '',
|
||||||
|
outbound_url: '',
|
||||||
|
webhook_secret: '',
|
||||||
|
outbound_token: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_META: Record<ChannelMessageStatus, {
|
||||||
|
label: string
|
||||||
|
variant: 'secondary' | 'info' | 'success' | 'danger' | 'warning'
|
||||||
|
}> = {
|
||||||
|
received: { label: '已接收', variant: 'info' },
|
||||||
|
queued: { label: '待投递', variant: 'warning' },
|
||||||
|
sent: { label: '已发送', variant: 'success' },
|
||||||
|
delivered: { label: '已送达', variant: 'success' },
|
||||||
|
failed: { label: '失败', variant: 'danger' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelManagementPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [endpoints, setEndpoints] = React.useState<ChannelEndpoint[]>([])
|
||||||
|
const [messages, setMessages] = React.useState<ChannelMessage[]>([])
|
||||||
|
const [loading, setLoading] = React.useState(true)
|
||||||
|
const [error, setError] = React.useState('')
|
||||||
|
const [busy, setBusy] = React.useState('')
|
||||||
|
const [endpointFilter, setEndpointFilter] = React.useState('all')
|
||||||
|
const [directionFilter, setDirectionFilter] = React.useState('all')
|
||||||
|
const [createOpen, setCreateOpen] = React.useState(false)
|
||||||
|
const [endpointForm, setEndpointForm] = React.useState(endpointInitial)
|
||||||
|
|
||||||
|
const hasPermission = React.useCallback((permission: string) => Boolean(
|
||||||
|
user?.is_superuser || user?.permissions.includes(permission)
|
||||||
|
), [user])
|
||||||
|
const canManage = hasPermission('channel.manage')
|
||||||
|
const canSend = hasPermission('channel.send') && hasPermission('ticket.reply')
|
||||||
|
|
||||||
|
const load = React.useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const endpointId = endpointFilter === 'all' ? undefined : Number(endpointFilter)
|
||||||
|
const direction = directionFilter === 'all' ? undefined : directionFilter
|
||||||
|
const [nextEndpoints, nextMessages] = await Promise.all([
|
||||||
|
listChannelEndpoints(),
|
||||||
|
listChannelMessages({ endpoint_id: endpointId, direction })
|
||||||
|
])
|
||||||
|
setEndpoints(nextEndpoints)
|
||||||
|
setMessages(nextMessages)
|
||||||
|
setError('')
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '渠道数据加载失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [directionFilter, endpointFilter])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
async function runAction(key: string, action: () => Promise<unknown>, success: string) {
|
||||||
|
setBusy(key)
|
||||||
|
try {
|
||||||
|
await action()
|
||||||
|
toast.success(success)
|
||||||
|
await load()
|
||||||
|
} catch (reason) {
|
||||||
|
toast.error(reason instanceof Error ? reason.message : '操作未完成')
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setBusy('create')
|
||||||
|
try {
|
||||||
|
await createChannelEndpoint({ ...endpointForm, enabled: true })
|
||||||
|
setEndpointForm(endpointInitial)
|
||||||
|
setCreateOpen(false)
|
||||||
|
toast.success('渠道适配器已创建')
|
||||||
|
await load()
|
||||||
|
} catch (reason) {
|
||||||
|
toast.error(reason instanceof Error ? reason.message : '渠道创建失败')
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageColumns: AppDataTableColumn<ChannelMessage>[] = [
|
||||||
|
{
|
||||||
|
key: 'direction',
|
||||||
|
header: '方向',
|
||||||
|
cell: (message) => (
|
||||||
|
<Badge variant={message.direction === 'inbound' ? 'info' : 'ai'}>
|
||||||
|
{message.direction === 'inbound' ? <ArrowDownToLine /> : <ArrowUpFromLine />}
|
||||||
|
{message.direction === 'inbound' ? '入站' : '出站'}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'channel',
|
||||||
|
header: '渠道 / 会话',
|
||||||
|
cell: (message) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{message.endpoint_name}</p>
|
||||||
|
<p className="max-w-48 truncate text-xs text-muted-foreground">
|
||||||
|
{message.external_conversation_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'message',
|
||||||
|
header: '消息',
|
||||||
|
cell: (message) => (
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<p className="line-clamp-2 text-sm">{message.text}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{message.sender || '平台'}{message.ticket_id ? ` · 工单 #${message.ticket_id}` : ''}
|
||||||
|
{message.customer_reply_task_id
|
||||||
|
? ` · 回复任务 #${message.customer_reply_task_id} v${message.reply_revision}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: '状态',
|
||||||
|
cell: (message) => (
|
||||||
|
<div>
|
||||||
|
<Badge variant={STATUS_META[message.status].variant}>{STATUS_META[message.status].label}</Badge>
|
||||||
|
{message.error_message ? (
|
||||||
|
<p className="mt-1 max-w-44 truncate text-xs text-danger" title={message.error_message}>
|
||||||
|
{message.error_message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'time',
|
||||||
|
header: '时间',
|
||||||
|
cell: (message) => (
|
||||||
|
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(message.occurred_at || message.created_at)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'action',
|
||||||
|
header: '',
|
||||||
|
cell: (message) => message.direction === 'outbound' && message.status === 'failed' && canSend ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy === `retry-${message.public_id}`}
|
||||||
|
onClick={() => void runAction(
|
||||||
|
`retry-${message.public_id}`,
|
||||||
|
() => retryChannelMessage(message.public_id),
|
||||||
|
'消息已重新进入投递队列'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RotateCcw data-icon="inline-start" />重试
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!hasPermission('channel.view')) {
|
||||||
|
return (
|
||||||
|
<AppPage header={<AppPageHeader title="渠道接入" description="管理服务群消息的幂等收发和回执。" />}>
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<CircleAlert />
|
||||||
|
<AlertTitle>没有查看权限</AlertTitle>
|
||||||
|
<AlertDescription>请联系管理员授予“查看渠道消息”权限。</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</AppPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inboundCount = messages.filter((message) => message.direction === 'inbound').length
|
||||||
|
const outboundCount = messages.filter((message) => message.direction === 'outbound').length
|
||||||
|
const failedCount = messages.filter((message) => message.status === 'failed').length
|
||||||
|
const metricCards: Array<{ label: string; value: number; icon: typeof KeyRound }> = [
|
||||||
|
{ label: '已启用渠道', value: endpoints.filter((endpoint) => endpoint.enabled).length, icon: KeyRound },
|
||||||
|
{ label: '入站消息', value: inboundCount, icon: ArrowDownToLine },
|
||||||
|
{ label: '出站消息', value: outboundCount, icon: ArrowUpFromLine },
|
||||||
|
{ label: '失败待处理', value: failedCount, icon: CircleAlert }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppPage
|
||||||
|
maxWidth="7xl"
|
||||||
|
contentClassName="gap-5"
|
||||||
|
header={(
|
||||||
|
<AppPageHeader
|
||||||
|
title="渠道接入"
|
||||||
|
description="用统一 Webhook 契约接入服务群,保证入站去重、出站幂等和回执可追溯。"
|
||||||
|
meta={<Badge variant="ai"><Webhook />Generic Webhook</Badge>}
|
||||||
|
actions={(
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => void load()} disabled={loading}>
|
||||||
|
<RefreshCw data-icon="inline-start" />刷新
|
||||||
|
</Button>
|
||||||
|
{canManage ? (
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus data-icon="inline-start" />接入渠道
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{metricCards.map(({ label, value, icon: Icon }) => (
|
||||||
|
<Card key={label} size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>{label}</CardDescription>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
|
||||||
|
<Icon className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<CircleAlert />
|
||||||
|
<AlertTitle>渠道数据加载失败</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid gap-3"><Skeleton className="h-10 w-72" /><Skeleton className="h-96" /></div>
|
||||||
|
) : (
|
||||||
|
<Tabs defaultValue="messages">
|
||||||
|
<TabsList variant="line">
|
||||||
|
<TabsTrigger value="messages">消息时间线</TabsTrigger>
|
||||||
|
<TabsTrigger value="endpoints">适配器</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="messages" className="mt-4">
|
||||||
|
<AppSection title="最近收发" description="最多展示最近 100 条消息;失败出站可沿用原幂等键重试。">
|
||||||
|
<AppDataTable
|
||||||
|
columns={messageColumns}
|
||||||
|
rows={messages}
|
||||||
|
getRowKey={(message) => message.public_id}
|
||||||
|
emptyTitle="暂无渠道消息"
|
||||||
|
emptyDescription="签名 Webhook 收到消息后会显示在这里。"
|
||||||
|
toolbar={(
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Select value={endpointFilter} onValueChange={setEndpointFilter}>
|
||||||
|
<SelectTrigger className="w-52"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent><SelectGroup>
|
||||||
|
<SelectItem value="all">全部渠道</SelectItem>
|
||||||
|
{endpoints.map((endpoint) => (
|
||||||
|
<SelectItem key={endpoint.id} value={String(endpoint.id)}>{endpoint.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup></SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={directionFilter} onValueChange={setDirectionFilter}>
|
||||||
|
<SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent><SelectGroup>
|
||||||
|
<SelectItem value="all">全部方向</SelectItem>
|
||||||
|
<SelectItem value="inbound">仅入站</SelectItem>
|
||||||
|
<SelectItem value="outbound">仅出站</SelectItem>
|
||||||
|
</SelectGroup></SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</AppSection>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="endpoints" className="mt-4">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{endpoints.length === 0 ? (
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<AppEmptyState title="还没有渠道适配器" description="创建适配器后,将签名 Webhook 地址配置到服务群机器人。" />
|
||||||
|
</div>
|
||||||
|
) : endpoints.map((endpoint) => {
|
||||||
|
const webhookUrl = `${window.location.origin}/api/channels/webhooks/${endpoint.public_id}/messages`
|
||||||
|
return (
|
||||||
|
<Card key={endpoint.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{endpoint.name}</CardTitle>
|
||||||
|
<CardDescription>{endpoint.adapter_type}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant={endpoint.enabled ? 'success' : 'secondary'}>
|
||||||
|
{endpoint.enabled ? '已启用' : '已停用'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-lg bg-muted/45 p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">入站 Webhook</p>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<code className="min-w-0 flex-1 truncate text-xs">{webhookUrl}</code>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`复制 ${endpoint.name} Webhook`}
|
||||||
|
onClick={() => void navigator.clipboard.writeText(webhookUrl).then(() => toast.success('Webhook 已复制'))}
|
||||||
|
>
|
||||||
|
<Copy />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||||
|
<span>签名密钥:{endpoint.webhook_secret_set ? '已配置' : '未配置'}</span>
|
||||||
|
<span>出站凭证:{endpoint.outbound_token_set ? '已配置' : '未配置'}</span>
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
出站地址:{endpoint.outbound_url || '未配置'}
|
||||||
|
</p>
|
||||||
|
{canManage ? (
|
||||||
|
<div className="flex items-center justify-between border-t pt-3">
|
||||||
|
<span className="text-sm">接收与发送</span>
|
||||||
|
<Switch
|
||||||
|
checked={endpoint.enabled}
|
||||||
|
disabled={busy === `endpoint-${endpoint.id}`}
|
||||||
|
onCheckedChange={(enabled) => void runAction(
|
||||||
|
`endpoint-${endpoint.id}`,
|
||||||
|
() => updateChannelEndpoint(endpoint.id, { enabled }),
|
||||||
|
enabled ? '渠道已启用' : '渠道已停用'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AppCreateDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
title="接入 Generic Webhook"
|
||||||
|
description="入站请求使用 HMAC-SHA256 签名;访问凭证保存后不再回显。"
|
||||||
|
submitLabel="创建适配器"
|
||||||
|
pending={busy === 'create'}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
>
|
||||||
|
<FieldGroup>
|
||||||
|
<AppField label="渠道名称" htmlFor="channel-name" required>
|
||||||
|
<Input id="channel-name" required autoFocus value={endpointForm.name} onChange={(event) => setEndpointForm({ ...endpointForm, name: event.target.value })} placeholder="例如:一线服务群" />
|
||||||
|
</AppField>
|
||||||
|
<AppField label="出站地址" htmlFor="channel-url" required>
|
||||||
|
<Input id="channel-url" required type="url" value={endpointForm.outbound_url} onChange={(event) => setEndpointForm({ ...endpointForm, outbound_url: event.target.value })} placeholder="https://channel.example/messages" />
|
||||||
|
</AppField>
|
||||||
|
<AppField label="Webhook 签名密钥" htmlFor="channel-secret" required>
|
||||||
|
<Input id="channel-secret" required type="password" autoComplete="new-password" value={endpointForm.webhook_secret} onChange={(event) => setEndpointForm({ ...endpointForm, webhook_secret: event.target.value })} />
|
||||||
|
</AppField>
|
||||||
|
<AppField label="出站 Bearer Token" htmlFor="channel-token">
|
||||||
|
<Input id="channel-token" type="password" autoComplete="new-password" value={endpointForm.outbound_token} onChange={(event) => setEndpointForm({ ...endpointForm, outbound_token: event.target.value })} />
|
||||||
|
</AppField>
|
||||||
|
</FieldGroup>
|
||||||
|
</AppCreateDialog>
|
||||||
|
|
||||||
|
</AppPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
frontend/src/features/channels/ChannelReplyDialog.test.tsx
Normal file
115
frontend/src/features/channels/ChannelReplyDialog.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import type { CustomerReplyTask } from '@/features/tickets/types'
|
||||||
|
|
||||||
|
import { ChannelReplyDialog } from './ChannelReplyDialog'
|
||||||
|
import { listChannelEndpoints, sendChannelMessage } from './api'
|
||||||
|
import type { ChannelEndpoint, ChannelMessage } from './types'
|
||||||
|
|
||||||
|
vi.mock('./api', () => ({
|
||||||
|
listChannelEndpoints: vi.fn(),
|
||||||
|
sendChannelMessage: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const endpoint: ChannelEndpoint = {
|
||||||
|
id: 8,
|
||||||
|
public_id: 'endpoint-8',
|
||||||
|
name: '客户服务群',
|
||||||
|
adapter_type: 'generic_webhook',
|
||||||
|
outbound_url: 'https://channel.example/messages',
|
||||||
|
webhook_secret_set: true,
|
||||||
|
outbound_token_set: true,
|
||||||
|
enabled: true,
|
||||||
|
created_at: '2026-07-27T08:00:00Z',
|
||||||
|
updated_at: '2026-07-27T08:00:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
const task: CustomerReplyTask = {
|
||||||
|
id: 15,
|
||||||
|
ticket_id: 7,
|
||||||
|
support_analysis_id: 9,
|
||||||
|
status: 'draft',
|
||||||
|
draft_text: '我们正在检查订单服务连接池状态。',
|
||||||
|
final_text: '连接池已恢复,请重新尝试。',
|
||||||
|
revision: 3,
|
||||||
|
created_by_id: 3,
|
||||||
|
updated_by_id: 3,
|
||||||
|
approved_by_id: null,
|
||||||
|
approved_at: null,
|
||||||
|
sent_by_id: null,
|
||||||
|
sent_at: null,
|
||||||
|
created_at: '2026-07-27T08:00:05Z',
|
||||||
|
updated_at: '2026-07-27T08:03:00Z',
|
||||||
|
revisions: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const queuedMessage = {
|
||||||
|
public_id: 'message-15-v3',
|
||||||
|
endpoint_id: endpoint.id,
|
||||||
|
endpoint_name: endpoint.name,
|
||||||
|
conversation_public_id: 'conversation-1',
|
||||||
|
external_conversation_id: 'group-001',
|
||||||
|
ticket_id: task.ticket_id,
|
||||||
|
customer_reply_task_id: task.id,
|
||||||
|
reply_revision: task.revision,
|
||||||
|
direction: 'outbound',
|
||||||
|
external_message_id: '',
|
||||||
|
idempotency_key: 'customer-reply-15-v3-test',
|
||||||
|
sender: '支持工程师',
|
||||||
|
message_type: 'text',
|
||||||
|
text: task.final_text,
|
||||||
|
payload: {},
|
||||||
|
status: 'queued',
|
||||||
|
error_message: '',
|
||||||
|
occurred_at: null,
|
||||||
|
sent_at: null,
|
||||||
|
delivered_at: null,
|
||||||
|
created_at: '2026-07-27T08:04:00Z',
|
||||||
|
updated_at: '2026-07-27T08:04:00Z'
|
||||||
|
} satisfies ChannelMessage
|
||||||
|
|
||||||
|
describe('ChannelReplyDialog', () => {
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(listChannelEndpoints).mockResolvedValue([endpoint])
|
||||||
|
vi.mocked(sendChannelMessage).mockResolvedValue(queuedMessage)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只提交冻结回复任务与当前版本,不接受任意文本', async () => {
|
||||||
|
const onQueued = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const onOpenChange = vi.fn()
|
||||||
|
render(
|
||||||
|
<ChannelReplyDialog
|
||||||
|
task={task}
|
||||||
|
open
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onQueued={onQueued}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect((await screen.findAllByText('客户服务群')).length).toBeGreaterThan(0)
|
||||||
|
fireEvent.change(screen.getByLabelText('外部会话 ID'), {
|
||||||
|
target: { value: 'group-001' }
|
||||||
|
})
|
||||||
|
const submit = screen.getByRole('button', { name: '冻结版本并发送' })
|
||||||
|
await waitFor(() => expect(submit).toBeEnabled())
|
||||||
|
fireEvent.click(submit)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(sendChannelMessage).toHaveBeenCalledWith(
|
||||||
|
endpoint.id,
|
||||||
|
expect.objectContaining({
|
||||||
|
external_conversation_id: 'group-001',
|
||||||
|
customer_reply_task_id: task.id,
|
||||||
|
expected_revision: task.revision
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const payload = vi.mocked(sendChannelMessage).mock.calls[0][1]
|
||||||
|
expect(payload).not.toHaveProperty('text')
|
||||||
|
expect(onQueued).toHaveBeenCalledWith(queuedMessage)
|
||||||
|
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
198
frontend/src/features/channels/ChannelReplyDialog.tsx
Normal file
198
frontend/src/features/channels/ChannelReplyDialog.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { CircleAlert, Send } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { AppField } from '@/components/app/AppField'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import type { CustomerReplyTask } from '@/features/tickets/types'
|
||||||
|
|
||||||
|
import { listChannelEndpoints, sendChannelMessage } from './api'
|
||||||
|
import type { ChannelEndpoint, ChannelMessage } from './types'
|
||||||
|
|
||||||
|
function stableHash(value: string) {
|
||||||
|
let hash = 2166136261
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
hash ^= value.charCodeAt(index)
|
||||||
|
hash = Math.imul(hash, 16777619)
|
||||||
|
}
|
||||||
|
return (hash >>> 0).toString(16).padStart(8, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelReplyDialog({
|
||||||
|
task,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onQueued
|
||||||
|
}: {
|
||||||
|
task: CustomerReplyTask | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onQueued: (message: ChannelMessage) => Promise<void> | void
|
||||||
|
}) {
|
||||||
|
const [endpoints, setEndpoints] = React.useState<ChannelEndpoint[]>([])
|
||||||
|
const [endpointId, setEndpointId] = React.useState('')
|
||||||
|
const [conversationId, setConversationId] = React.useState('')
|
||||||
|
const [loading, setLoading] = React.useState(false)
|
||||||
|
const [sending, setSending] = React.useState(false)
|
||||||
|
const [error, setError] = React.useState('')
|
||||||
|
const selectedEndpointId = endpointId || String(endpoints[0]?.id ?? '')
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
listChannelEndpoints()
|
||||||
|
.then((items) => {
|
||||||
|
const enabled = items.filter((item) => item.enabled)
|
||||||
|
setEndpoints(enabled)
|
||||||
|
setEndpointId((current) => current || String(enabled[0]?.id ?? ''))
|
||||||
|
})
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
setEndpoints([])
|
||||||
|
setError(reason instanceof Error ? reason.message : '无法加载可用渠道')
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!task) return
|
||||||
|
const normalizedConversation = conversationId.trim()
|
||||||
|
const numericEndpointId = Number(selectedEndpointId)
|
||||||
|
if (!numericEndpointId || !normalizedConversation) return
|
||||||
|
setSending(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const message = await sendChannelMessage(numericEndpointId, {
|
||||||
|
external_conversation_id: normalizedConversation,
|
||||||
|
customer_reply_task_id: task.id,
|
||||||
|
expected_revision: task.revision,
|
||||||
|
idempotency_key: [
|
||||||
|
'customer-reply',
|
||||||
|
task.id,
|
||||||
|
`v${task.revision}`,
|
||||||
|
stableHash(`${numericEndpointId}:${normalizedConversation}`)
|
||||||
|
].join('-')
|
||||||
|
})
|
||||||
|
await onQueued(message)
|
||||||
|
toast.success('客户回复已进入渠道投递队列')
|
||||||
|
setConversationId('')
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '客户回复未能进入渠道队列')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>通过渠道发送客户回复</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
发送后将冻结当前回复版本;只有渠道送达回执会自动确认客户已收到。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{task ? (
|
||||||
|
<form id="channel-reply-form" onSubmit={handleSubmit}>
|
||||||
|
<FieldGroup>
|
||||||
|
<Alert>
|
||||||
|
<Send />
|
||||||
|
<AlertTitle>即将发送回复任务 #{task.id}</AlertTitle>
|
||||||
|
<AlertDescription className="flex flex-col gap-2">
|
||||||
|
<Badge variant="outline" className="w-fit">冻结版本 v{task.revision}</Badge>
|
||||||
|
<span className="line-clamp-4 whitespace-pre-wrap">{task.final_text}</span>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<AppField label="发送渠道" required>
|
||||||
|
<Select
|
||||||
|
value={selectedEndpointId}
|
||||||
|
disabled={loading || sending}
|
||||||
|
onValueChange={setEndpointId}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={loading ? '正在加载渠道' : '选择已启用渠道'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{endpoints.map((endpoint) => (
|
||||||
|
<SelectItem key={endpoint.id} value={String(endpoint.id)}>
|
||||||
|
{endpoint.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</AppField>
|
||||||
|
<AppField
|
||||||
|
label="外部会话 ID"
|
||||||
|
htmlFor="channel-reply-conversation"
|
||||||
|
required
|
||||||
|
description="会话首次关联后不能改绑到其他工单。"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="channel-reply-conversation"
|
||||||
|
aria-label="外部会话 ID"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
disabled={sending}
|
||||||
|
value={conversationId}
|
||||||
|
onChange={(event) => setConversationId(event.target.value)}
|
||||||
|
placeholder="例如:group-20260727-001"
|
||||||
|
/>
|
||||||
|
</AppField>
|
||||||
|
{!loading && endpoints.length === 0 ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<CircleAlert />
|
||||||
|
<AlertTitle>没有可用渠道</AlertTitle>
|
||||||
|
<AlertDescription>请先在“渠道接入”中创建并启用适配器。</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<CircleAlert />
|
||||||
|
<AlertTitle>渠道发送未完成</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="channel-reply-form"
|
||||||
|
disabled={!task || !selectedEndpointId || !conversationId.trim() || sending}
|
||||||
|
>
|
||||||
|
<Send data-icon="inline-start" />
|
||||||
|
{sending ? '正在排队' : '冻结版本并发送'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
frontend/src/features/channels/api.ts
Normal file
68
frontend/src/features/channels/api.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { apiRequest } from '@/lib/api'
|
||||||
|
|
||||||
|
import type { ChannelEndpoint, ChannelMessage } from './types'
|
||||||
|
|
||||||
|
export function listChannelEndpoints() {
|
||||||
|
return apiRequest<ChannelEndpoint[]>('/channels/endpoints')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChannelEndpoint(payload: {
|
||||||
|
name: string
|
||||||
|
outbound_url: string
|
||||||
|
webhook_secret: string
|
||||||
|
outbound_token: string
|
||||||
|
enabled: boolean
|
||||||
|
}) {
|
||||||
|
return apiRequest<ChannelEndpoint>('/channels/endpoints', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ...payload, adapter_type: 'generic_webhook' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateChannelEndpoint(
|
||||||
|
endpointId: number,
|
||||||
|
payload: Partial<Pick<ChannelEndpoint, 'name' | 'outbound_url' | 'enabled'>> & {
|
||||||
|
webhook_secret?: string
|
||||||
|
outbound_token?: string
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return apiRequest<ChannelEndpoint>(`/channels/endpoints/${endpointId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listChannelMessages(filters?: {
|
||||||
|
endpoint_id?: number
|
||||||
|
direction?: string
|
||||||
|
status?: string
|
||||||
|
}) {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
if (filters?.endpoint_id) search.set('endpoint_id', String(filters.endpoint_id))
|
||||||
|
if (filters?.direction) search.set('direction', filters.direction)
|
||||||
|
if (filters?.status) search.set('status', filters.status)
|
||||||
|
const suffix = search.size ? `?${search.toString()}` : ''
|
||||||
|
return apiRequest<ChannelMessage[]>(`/channels/messages${suffix}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendChannelMessage(
|
||||||
|
endpointId: number,
|
||||||
|
payload: {
|
||||||
|
external_conversation_id: string
|
||||||
|
idempotency_key: string
|
||||||
|
customer_reply_task_id: number
|
||||||
|
expected_revision: number
|
||||||
|
payload?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return apiRequest<ChannelMessage>(`/channels/endpoints/${endpointId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retryChannelMessage(publicId: string) {
|
||||||
|
return apiRequest<ChannelMessage>(`/channels/messages/${publicId}/retry`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
}
|
||||||
40
frontend/src/features/channels/types.ts
Normal file
40
frontend/src/features/channels/types.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export type ChannelEndpoint = {
|
||||||
|
id: number
|
||||||
|
public_id: string
|
||||||
|
name: string
|
||||||
|
adapter_type: 'generic_webhook'
|
||||||
|
outbound_url: string
|
||||||
|
webhook_secret_set: boolean
|
||||||
|
outbound_token_set: boolean
|
||||||
|
enabled: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChannelMessageDirection = 'inbound' | 'outbound'
|
||||||
|
export type ChannelMessageStatus = 'received' | 'queued' | 'sent' | 'delivered' | 'failed'
|
||||||
|
|
||||||
|
export type ChannelMessage = {
|
||||||
|
public_id: string
|
||||||
|
endpoint_id: number
|
||||||
|
endpoint_name: string
|
||||||
|
conversation_public_id: string | null
|
||||||
|
external_conversation_id: string
|
||||||
|
ticket_id: number | null
|
||||||
|
customer_reply_task_id: number | null
|
||||||
|
reply_revision: number | null
|
||||||
|
direction: ChannelMessageDirection
|
||||||
|
external_message_id: string
|
||||||
|
idempotency_key: string
|
||||||
|
sender: string
|
||||||
|
message_type: string
|
||||||
|
text: string
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
status: ChannelMessageStatus
|
||||||
|
error_message: string
|
||||||
|
occurred_at: string | null
|
||||||
|
sent_at: string | null
|
||||||
|
delivered_at: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
396
frontend/src/features/system/IdentityProviderDialog.tsx
Normal file
396
frontend/src/features/system/IdentityProviderDialog.tsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { CircleAlert, LoaderCircle, Save } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
|
||||||
|
import { createIdentityProvider, updateIdentityProvider } from './api'
|
||||||
|
import type { IdentityProvider, IdentityProviderInput } from './types'
|
||||||
|
|
||||||
|
type ProviderForm = IdentityProviderInput & {
|
||||||
|
slug: string
|
||||||
|
provider_type: 'oidc' | 'ldap'
|
||||||
|
server_uri: string
|
||||||
|
base_dn: string
|
||||||
|
bind_dn: string
|
||||||
|
user_filter: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: ProviderForm = {
|
||||||
|
slug: '',
|
||||||
|
name: '',
|
||||||
|
provider_type: 'oidc',
|
||||||
|
enabled: false,
|
||||||
|
button_label: '',
|
||||||
|
issuer: '',
|
||||||
|
metadata_url: '',
|
||||||
|
client_id: '',
|
||||||
|
credential: '',
|
||||||
|
scopes: ['openid', 'profile', 'email'],
|
||||||
|
claim_mappings: {
|
||||||
|
subject: 'sub',
|
||||||
|
username: 'preferred_username',
|
||||||
|
email: 'email',
|
||||||
|
display_name: 'name',
|
||||||
|
groups: 'groups'
|
||||||
|
},
|
||||||
|
configuration: {},
|
||||||
|
auto_create_users: true,
|
||||||
|
sync_profile: true,
|
||||||
|
sync_groups: true,
|
||||||
|
server_uri: '',
|
||||||
|
base_dn: '',
|
||||||
|
bind_dn: '',
|
||||||
|
user_filter: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdentityProviderDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
provider: IdentityProvider | null
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onSaved: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IdentityProviderDialog({
|
||||||
|
open,
|
||||||
|
provider,
|
||||||
|
onOpenChange,
|
||||||
|
onSaved
|
||||||
|
}: IdentityProviderDialogProps) {
|
||||||
|
const [form, setForm] = React.useState<ProviderForm>(emptyForm)
|
||||||
|
const [pending, setPending] = React.useState(false)
|
||||||
|
const [error, setError] = React.useState('')
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
if (!provider) {
|
||||||
|
setForm(emptyForm)
|
||||||
|
setError('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setForm({
|
||||||
|
...emptyForm,
|
||||||
|
slug: provider.slug,
|
||||||
|
name: provider.name,
|
||||||
|
provider_type: provider.provider_type,
|
||||||
|
enabled: provider.enabled,
|
||||||
|
button_label: provider.button_label,
|
||||||
|
issuer: provider.issuer,
|
||||||
|
metadata_url: provider.metadata_url,
|
||||||
|
client_id: provider.client_id,
|
||||||
|
scopes: provider.scopes,
|
||||||
|
claim_mappings: {
|
||||||
|
...emptyForm.claim_mappings,
|
||||||
|
...provider.claim_mappings
|
||||||
|
},
|
||||||
|
configuration: provider.configuration,
|
||||||
|
auto_create_users: provider.auto_create_users,
|
||||||
|
sync_profile: provider.sync_profile,
|
||||||
|
sync_groups: provider.sync_groups,
|
||||||
|
server_uri: String(provider.configuration.server_uri ?? ''),
|
||||||
|
base_dn: String(provider.configuration.base_dn ?? ''),
|
||||||
|
bind_dn: String(provider.configuration.bind_dn ?? ''),
|
||||||
|
user_filter: String(provider.configuration.user_filter ?? '')
|
||||||
|
})
|
||||||
|
setError('')
|
||||||
|
}, [open, provider])
|
||||||
|
|
||||||
|
function setClaim(key: string, value: string) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
claim_mappings: { ...current.claim_mappings, [key]: value }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setPending(true)
|
||||||
|
setError('')
|
||||||
|
const configuration = form.provider_type === 'ldap'
|
||||||
|
? {
|
||||||
|
...form.configuration,
|
||||||
|
server_uri: form.server_uri.trim(),
|
||||||
|
base_dn: form.base_dn.trim(),
|
||||||
|
bind_dn: form.bind_dn.trim(),
|
||||||
|
user_filter: form.user_filter.trim()
|
||||||
|
}
|
||||||
|
: form.configuration
|
||||||
|
const payload: IdentityProviderInput = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
enabled: form.enabled,
|
||||||
|
button_label: form.button_label.trim(),
|
||||||
|
issuer: form.issuer.trim(),
|
||||||
|
metadata_url: form.metadata_url.trim(),
|
||||||
|
client_id: form.client_id.trim(),
|
||||||
|
credential: form.credential?.trim() || undefined,
|
||||||
|
scopes: form.scopes,
|
||||||
|
claim_mappings: form.claim_mappings,
|
||||||
|
configuration,
|
||||||
|
auto_create_users: form.auto_create_users,
|
||||||
|
sync_profile: form.sync_profile,
|
||||||
|
sync_groups: form.sync_groups
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (provider) {
|
||||||
|
await updateIdentityProvider(provider.id, payload)
|
||||||
|
} else {
|
||||||
|
await createIdentityProvider({
|
||||||
|
...payload,
|
||||||
|
slug: form.slug.trim(),
|
||||||
|
provider_type: form.provider_type
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onOpenChange(false)
|
||||||
|
await onSaved()
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '身份提供方保存失败')
|
||||||
|
} finally {
|
||||||
|
setPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const oidc = form.provider_type === 'oidc'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="overflow-hidden p-0 sm:max-w-2xl">
|
||||||
|
<DialogHeader className="border-b bg-muted/35 px-6 py-5 pr-12">
|
||||||
|
<DialogTitle>{provider ? '编辑身份提供方' : '新增身份提供方'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
密钥只在保存时提交,页面与审计日志不会返回明文。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="scrollbar-panel max-h-[68vh] overflow-y-auto px-6 py-5">
|
||||||
|
<FieldGroup>
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="identity-name">名称</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="identity-name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||||
|
placeholder="例如:公司统一身份"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field data-disabled={Boolean(provider)}>
|
||||||
|
<FieldLabel htmlFor="identity-slug">稳定标识</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="identity-slug"
|
||||||
|
value={form.slug}
|
||||||
|
onChange={(event) => setForm({ ...form, slug: event.target.value })}
|
||||||
|
placeholder="corp-identity"
|
||||||
|
disabled={Boolean(provider)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field data-disabled={Boolean(provider)}>
|
||||||
|
<FieldLabel>协议类型</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={form.provider_type}
|
||||||
|
onValueChange={(value: 'oidc' | 'ldap') => setForm({ ...form, provider_type: value })}
|
||||||
|
disabled={Boolean(provider)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="oidc">OpenID Connect</SelectItem>
|
||||||
|
<SelectItem value="ldap">LDAP 目录适配器</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldDescription>
|
||||||
|
LDAP 首批提供配置与目录同步适配边界;浏览器单点登录仅由 OIDC 提供。
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{oidc ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="identity-issuer">Issuer</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="identity-issuer"
|
||||||
|
type="url"
|
||||||
|
value={form.issuer}
|
||||||
|
onChange={(event) => setForm({ ...form, issuer: event.target.value })}
|
||||||
|
placeholder="https://idp.example.com"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="identity-metadata">发现文档地址</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="identity-metadata"
|
||||||
|
type="url"
|
||||||
|
value={form.metadata_url}
|
||||||
|
onChange={(event) => setForm({ ...form, metadata_url: event.target.value })}
|
||||||
|
placeholder="留空时由 Issuer 推导"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="identity-client-id">Client ID</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="identity-client-id"
|
||||||
|
value={form.client_id}
|
||||||
|
onChange={(event) => setForm({ ...form, client_id: event.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="identity-credential">
|
||||||
|
Client secret {provider?.credential_set ? `(${provider.credential_masked})` : ''}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="identity-credential"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={form.credential}
|
||||||
|
onChange={(event) => setForm({ ...form, credential: event.target.value })}
|
||||||
|
placeholder={provider?.credential_set ? '留空保留原密钥' : '输入密钥'}
|
||||||
|
required={!provider?.credential_set && form.enabled}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="identity-button-label">登录按钮文案</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="identity-button-label"
|
||||||
|
value={form.button_label}
|
||||||
|
onChange={(event) => setForm({ ...form, button_label: event.target.value })}
|
||||||
|
placeholder={`使用 ${form.name || '企业账号'} 登录`}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
{[
|
||||||
|
['subject', 'Subject 声明'],
|
||||||
|
['username', '账号声明'],
|
||||||
|
['email', '邮箱声明'],
|
||||||
|
['display_name', '显示名称声明'],
|
||||||
|
['groups', '组织声明']
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<Field key={key}>
|
||||||
|
<FieldLabel htmlFor={`identity-claim-${key}`}>{label}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`identity-claim-${key}`}
|
||||||
|
value={form.claim_mappings[key] ?? ''}
|
||||||
|
onChange={(event) => setClaim(key, event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="identity-server-uri">LDAP 服务地址</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="identity-server-uri"
|
||||||
|
value={form.server_uri}
|
||||||
|
onChange={(event) => setForm({ ...form, server_uri: event.target.value })}
|
||||||
|
placeholder="ldaps://directory.example.com"
|
||||||
|
required={form.enabled}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="identity-base-dn">Base DN</FieldLabel>
|
||||||
|
<Input id="identity-base-dn" value={form.base_dn} onChange={(event) => setForm({ ...form, base_dn: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="identity-bind-dn">Bind DN</FieldLabel>
|
||||||
|
<Input id="identity-bind-dn" value={form.bind_dn} onChange={(event) => setForm({ ...form, bind_dn: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="identity-user-filter">用户过滤器</FieldLabel>
|
||||||
|
<Input id="identity-user-filter" value={form.user_filter} onChange={(event) => setForm({ ...form, user_filter: event.target.value })} placeholder="(objectClass=person)" />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="identity-bind-password">
|
||||||
|
Bind password {provider?.credential_set ? `(${provider.credential_masked})` : ''}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="identity-bind-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={form.credential}
|
||||||
|
onChange={(event) => setForm({ ...form, credential: event.target.value })}
|
||||||
|
placeholder={provider?.credential_set ? '留空保留原密码' : '支持匿名绑定时可留空'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<FieldLabel htmlFor="identity-auto-create">自动创建本地账号</FieldLabel>
|
||||||
|
<FieldDescription>首次识别到稳定外部身份时创建无本地密码的账号。</FieldDescription>
|
||||||
|
</div>
|
||||||
|
<Switch id="identity-auto-create" checked={form.auto_create_users} onCheckedChange={(checked) => setForm({ ...form, auto_create_users: checked })} />
|
||||||
|
</Field>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<FieldLabel htmlFor="identity-sync-profile">同步姓名与邮箱</FieldLabel>
|
||||||
|
<FieldDescription>企业目录中的最新资料会覆盖对应本地字段。</FieldDescription>
|
||||||
|
</div>
|
||||||
|
<Switch id="identity-sync-profile" checked={form.sync_profile} onCheckedChange={(checked) => setForm({ ...form, sync_profile: checked })} />
|
||||||
|
</Field>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<FieldLabel htmlFor="identity-sync-groups">同步组织映射</FieldLabel>
|
||||||
|
<FieldDescription>只处理本提供方已登记的外部组织映射。</FieldDescription>
|
||||||
|
</div>
|
||||||
|
<Switch id="identity-sync-groups" checked={form.sync_groups} onCheckedChange={(checked) => setForm({ ...form, sync_groups: checked })} />
|
||||||
|
</Field>
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<FieldLabel htmlFor="identity-enabled">启用提供方</FieldLabel>
|
||||||
|
<FieldDescription>{oidc ? '启用后会出现在登录页。' : '启用后允许目录适配器使用该配置。'}</FieldDescription>
|
||||||
|
</div>
|
||||||
|
<Switch id="identity-enabled" checked={form.enabled} onCheckedChange={(checked) => setForm({ ...form, enabled: checked })} />
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive" className="mt-5">
|
||||||
|
<CircleAlert />
|
||||||
|
<AlertTitle>保存未完成</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="m-0 rounded-none px-6 py-4">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>取消</Button>
|
||||||
|
<Button type="submit" disabled={pending || !form.name.trim() || !form.slug.trim()}>
|
||||||
|
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" /> : <Save data-icon="inline-start" />}
|
||||||
|
{pending ? '保存中' : '保存提供方'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
frontend/src/features/system/SystemIdentityPage.test.tsx
Normal file
107
frontend/src/features/system/SystemIdentityPage.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { SystemIdentityPage } from './SystemIdentityPage'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
listIdentityProviders: vi.fn(),
|
||||||
|
listUserGroups: vi.fn(),
|
||||||
|
listEnterpriseGroupMappings: vi.fn(),
|
||||||
|
createEnterpriseGroupMapping: vi.fn(),
|
||||||
|
deleteEnterpriseGroupMapping: vi.fn(),
|
||||||
|
syncEnterpriseDirectory: vi.fn(),
|
||||||
|
updateIdentityProvider: vi.fn(),
|
||||||
|
createIdentityProvider: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./api', () => ({
|
||||||
|
...mocks
|
||||||
|
}))
|
||||||
|
|
||||||
|
const provider = {
|
||||||
|
id: 1,
|
||||||
|
slug: 'corp',
|
||||||
|
name: '企业统一身份',
|
||||||
|
provider_type: 'oidc' as const,
|
||||||
|
enabled: true,
|
||||||
|
button_label: '使用企业账号登录',
|
||||||
|
issuer: 'https://idp.example.com',
|
||||||
|
metadata_url: '',
|
||||||
|
client_id: 'ticket-platform',
|
||||||
|
credential_set: true,
|
||||||
|
credential_masked: '****cret',
|
||||||
|
scopes: ['openid', 'profile', 'email'],
|
||||||
|
claim_mappings: {},
|
||||||
|
configuration: {},
|
||||||
|
auto_create_users: true,
|
||||||
|
sync_profile: true,
|
||||||
|
sync_groups: true,
|
||||||
|
identity_count: 12,
|
||||||
|
group_mapping_count: 1,
|
||||||
|
created_at: '2026-07-27T00:00:00Z',
|
||||||
|
updated_at: '2026-07-27T00:00:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SystemIdentityPage', () => {
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mocks.listIdentityProviders.mockResolvedValue([provider])
|
||||||
|
mocks.listUserGroups.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
name: '一线支持',
|
||||||
|
code: 'support_l1',
|
||||||
|
description: '',
|
||||||
|
is_active: true
|
||||||
|
}
|
||||||
|
])
|
||||||
|
mocks.listEnterpriseGroupMappings.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 20,
|
||||||
|
provider_id: 1,
|
||||||
|
external_group: 'IT-Support',
|
||||||
|
user_group_id: 10,
|
||||||
|
user_group_name: '一线支持',
|
||||||
|
user_group_code: 'support_l1',
|
||||||
|
created_at: '2026-07-27T00:00:00Z'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
mocks.syncEnterpriseDirectory.mockResolvedValue({
|
||||||
|
id: 30,
|
||||||
|
provider_id: 1,
|
||||||
|
status: 'completed',
|
||||||
|
total_count: 1,
|
||||||
|
created_count: 1,
|
||||||
|
updated_count: 0,
|
||||||
|
failed_count: 0,
|
||||||
|
errors: [],
|
||||||
|
started_at: '2026-07-27T00:00:00Z',
|
||||||
|
finished_at: '2026-07-27T00:00:01Z'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders provider status and controlled group mapping', async () => {
|
||||||
|
render(<SystemIdentityPage />)
|
||||||
|
|
||||||
|
expect((await screen.findAllByText('企业统一身份')).length).toBeGreaterThan(0)
|
||||||
|
expect(await screen.findByText('IT-Support')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('12 个身份 · 1 个映射')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits normalized directory records and reports the run', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<SystemIdentityPage />)
|
||||||
|
await screen.findByText('IT-Support')
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('tab', { name: /目录同步/ }))
|
||||||
|
await user.click(await screen.findByRole('button', { name: '开始目录同步' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.syncEnterpriseDirectory).toHaveBeenCalledTimes(1))
|
||||||
|
expect(mocks.syncEnterpriseDirectory.mock.calls[0][0]).toBe(1)
|
||||||
|
expect(mocks.syncEnterpriseDirectory.mock.calls[0][1][0].subject).toBe('employee-1001')
|
||||||
|
expect(await screen.findByText(/同步运行 #30/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
432
frontend/src/features/system/SystemIdentityPage.tsx
Normal file
432
frontend/src/features/system/SystemIdentityPage.tsx
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Building2, Link2, Pencil, Plus, RefreshCw, ShieldCheck, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
|
||||||
|
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||||
|
import { AppPage } from '@/components/app/AppPage'
|
||||||
|
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { formatDateTime } from '@/lib/format'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createEnterpriseGroupMapping,
|
||||||
|
deleteEnterpriseGroupMapping,
|
||||||
|
listEnterpriseGroupMappings,
|
||||||
|
listIdentityProviders,
|
||||||
|
listUserGroups,
|
||||||
|
syncEnterpriseDirectory,
|
||||||
|
updateIdentityProvider
|
||||||
|
} from './api'
|
||||||
|
import { IdentityProviderDialog } from './IdentityProviderDialog'
|
||||||
|
import type {
|
||||||
|
DirectorySyncRun,
|
||||||
|
EnterpriseGroupMapping,
|
||||||
|
IdentityProvider,
|
||||||
|
UserGroup
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const directorySample = JSON.stringify([
|
||||||
|
{
|
||||||
|
subject: 'employee-1001',
|
||||||
|
username: 'zhangsan',
|
||||||
|
email: 'zhangsan@example.com',
|
||||||
|
display_name: '张三',
|
||||||
|
active: true,
|
||||||
|
groups: ['IT-Support']
|
||||||
|
}
|
||||||
|
], null, 2)
|
||||||
|
|
||||||
|
export function SystemIdentityPage() {
|
||||||
|
const [providers, setProviders] = React.useState<IdentityProvider[]>([])
|
||||||
|
const [userGroups, setUserGroups] = React.useState<UserGroup[]>([])
|
||||||
|
const [selectedId, setSelectedId] = React.useState<number | null>(null)
|
||||||
|
const [mappings, setMappings] = React.useState<EnterpriseGroupMapping[]>([])
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false)
|
||||||
|
const [editingProvider, setEditingProvider] = React.useState<IdentityProvider | null>(null)
|
||||||
|
const [externalGroup, setExternalGroup] = React.useState('')
|
||||||
|
const [userGroupId, setUserGroupId] = React.useState('')
|
||||||
|
const [directoryJson, setDirectoryJson] = React.useState(directorySample)
|
||||||
|
const [syncRun, setSyncRun] = React.useState<DirectorySyncRun | null>(null)
|
||||||
|
const [mappingToDelete, setMappingToDelete] = React.useState<EnterpriseGroupMapping | null>(null)
|
||||||
|
const [pending, setPending] = React.useState<string | null>(null)
|
||||||
|
const [error, setError] = React.useState('')
|
||||||
|
|
||||||
|
const selected = providers.find((provider) => provider.id === selectedId) ?? null
|
||||||
|
|
||||||
|
const loadProviders = React.useCallback(async () => {
|
||||||
|
const next = await listIdentityProviders()
|
||||||
|
setProviders(next)
|
||||||
|
setSelectedId((current) => current && next.some((item) => item.id === current) ? current : next[0]?.id ?? null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
Promise.all([loadProviders(), listUserGroups().then(setUserGroups)])
|
||||||
|
.then(() => setError(''))
|
||||||
|
.catch((reason) => setError(reason instanceof Error ? reason.message : '身份治理配置加载失败'))
|
||||||
|
}, [loadProviders])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!selectedId) {
|
||||||
|
setMappings([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void listEnterpriseGroupMappings(selectedId)
|
||||||
|
.then(setMappings)
|
||||||
|
.catch((reason) => setError(reason instanceof Error ? reason.message : '组织映射加载失败'))
|
||||||
|
}, [selectedId])
|
||||||
|
|
||||||
|
async function toggleProvider(provider: IdentityProvider, enabled: boolean) {
|
||||||
|
setPending(`provider-${provider.id}`)
|
||||||
|
try {
|
||||||
|
await updateIdentityProvider(provider.id, { enabled })
|
||||||
|
await loadProviders()
|
||||||
|
toast.success(enabled ? '身份提供方已启用' : '身份提供方已停用')
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '身份提供方状态更新失败')
|
||||||
|
} finally {
|
||||||
|
setPending(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMapping(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!selected || !userGroupId) return
|
||||||
|
setPending('mapping')
|
||||||
|
try {
|
||||||
|
await createEnterpriseGroupMapping(selected.id, externalGroup.trim(), Number(userGroupId))
|
||||||
|
setExternalGroup('')
|
||||||
|
setUserGroupId('')
|
||||||
|
setMappings(await listEnterpriseGroupMappings(selected.id))
|
||||||
|
await loadProviders()
|
||||||
|
toast.success('组织映射已创建')
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '组织映射创建失败')
|
||||||
|
} finally {
|
||||||
|
setPending(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMapping(mapping: EnterpriseGroupMapping) {
|
||||||
|
setPending(`mapping-${mapping.id}`)
|
||||||
|
try {
|
||||||
|
await deleteEnterpriseGroupMapping(mapping.id)
|
||||||
|
setMappings(await listEnterpriseGroupMappings(mapping.provider_id))
|
||||||
|
await loadProviders()
|
||||||
|
setMappingToDelete(null)
|
||||||
|
toast.success('组织映射已删除')
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '组织映射删除失败')
|
||||||
|
} finally {
|
||||||
|
setPending(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSync(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!selected) return
|
||||||
|
setPending('sync')
|
||||||
|
setSyncRun(null)
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(directoryJson)
|
||||||
|
if (!Array.isArray(parsed)) throw new Error('目录数据必须是 JSON 数组')
|
||||||
|
const run = await syncEnterpriseDirectory(selected.id, parsed)
|
||||||
|
setSyncRun(run)
|
||||||
|
await loadProviders()
|
||||||
|
toast.success(run.failed_count ? '目录同步已完成,部分记录需处理' : '目录同步已完成')
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : '目录同步失败')
|
||||||
|
} finally {
|
||||||
|
setPending(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerColumns: AppDataTableColumn<IdentityProvider>[] = [
|
||||||
|
{
|
||||||
|
key: 'provider',
|
||||||
|
header: '身份提供方',
|
||||||
|
cell: (provider) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{provider.name}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{provider.slug}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
header: '协议',
|
||||||
|
cell: (provider) => <Badge variant="outline">{provider.provider_type === 'oidc' ? 'OIDC' : 'LDAP'}</Badge>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'counts',
|
||||||
|
header: '同步范围',
|
||||||
|
cell: (provider) => (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{provider.identity_count} 个身份 · {provider.group_mapping_count} 个映射
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: '状态',
|
||||||
|
cell: (provider) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={provider.enabled}
|
||||||
|
disabled={pending === `provider-${provider.id}`}
|
||||||
|
onCheckedChange={(checked) => void toggleProvider(provider, checked)}
|
||||||
|
aria-label={`${provider.name}启用状态`}
|
||||||
|
/>
|
||||||
|
<Badge variant={provider.enabled ? 'success' : 'secondary'}>{provider.enabled ? '已启用' : '未启用'}</Badge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
cell: (provider) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingProvider(provider)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil data-icon="inline-start" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const mappingColumns: AppDataTableColumn<EnterpriseGroupMapping>[] = [
|
||||||
|
{
|
||||||
|
key: 'external',
|
||||||
|
header: '企业组织标识',
|
||||||
|
cell: (mapping) => <span className="font-mono text-sm">{mapping.external_group}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'local',
|
||||||
|
header: '平台用户组',
|
||||||
|
cell: (mapping) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{mapping.user_group_name}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{mapping.user_group_code}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'created',
|
||||||
|
header: '创建时间',
|
||||||
|
cell: (mapping) => <span className="text-muted-foreground">{formatDateTime(mapping.created_at)}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
cell: (mapping) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`删除 ${mapping.external_group} 映射`}
|
||||||
|
disabled={pending === `mapping-${mapping.id}`}
|
||||||
|
onClick={() => setMappingToDelete(mapping)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppPage
|
||||||
|
header={(
|
||||||
|
<AppPageHeader
|
||||||
|
title="企业身份"
|
||||||
|
description="统一管理单点登录、外部身份绑定与企业组织映射。"
|
||||||
|
actions={(
|
||||||
|
<Button onClick={() => { setEditingProvider(null); setDialogOpen(true) }}>
|
||||||
|
<Plus data-icon="inline-start" />
|
||||||
|
新增提供方
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>操作未完成</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AppDataTable
|
||||||
|
columns={providerColumns}
|
||||||
|
rows={providers}
|
||||||
|
getRowKey={(provider) => provider.id}
|
||||||
|
emptyTitle="尚未配置企业身份"
|
||||||
|
emptyDescription="新增 OIDC 提供方后,启用的企业登录入口会出现在登录页。"
|
||||||
|
footer={<p className="text-sm text-muted-foreground">共 {providers.length} 个身份提供方</p>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2"><Building2 />组织同步工作区</CardTitle>
|
||||||
|
<CardDescription className="mt-1">选择提供方,维护受控映射或通过目录适配器导入成员。</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Select value={String(selected.id)} onValueChange={(value) => setSelectedId(Number(value))}>
|
||||||
|
<SelectTrigger className="w-full sm:w-64"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{providers.map((provider) => <SelectItem key={provider.id} value={String(provider.id)}>{provider.name}</SelectItem>)}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="mappings">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="mappings"><Link2 />组织映射</TabsTrigger>
|
||||||
|
<TabsTrigger value="sync"><RefreshCw />目录同步</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="mappings" className="mt-4 flex flex-col gap-4">
|
||||||
|
<form onSubmit={addMapping}>
|
||||||
|
<FieldGroup className="grid items-end gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="external-group">企业组织标识</FieldLabel>
|
||||||
|
<Input id="external-group" value={externalGroup} onChange={(event) => setExternalGroup(event.target.value)} placeholder="例如:IT-Support" required />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>平台用户组</FieldLabel>
|
||||||
|
<Select value={userGroupId} onValueChange={setUserGroupId}>
|
||||||
|
<SelectTrigger className="w-full"><SelectValue placeholder="选择用户组" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{userGroups.filter((group) => group.is_active).map((group) => <SelectItem key={group.id} value={String(group.id)}>{group.name}</SelectItem>)}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Button type="submit" disabled={pending === 'mapping' || !externalGroup.trim() || !userGroupId}>
|
||||||
|
<Plus data-icon="inline-start" />
|
||||||
|
创建映射
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
<AppDataTable
|
||||||
|
columns={mappingColumns}
|
||||||
|
rows={mappings}
|
||||||
|
getRowKey={(mapping) => mapping.id}
|
||||||
|
emptyTitle="暂无组织映射"
|
||||||
|
emptyDescription="没有映射的企业组织不会改变平台中的用户组归属。"
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="sync" className="mt-4">
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={runSync}>
|
||||||
|
<Alert>
|
||||||
|
<ShieldCheck />
|
||||||
|
<AlertTitle>目录适配器输入</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
该入口接收已由可信连接器读取并标准化的成员记录;不会在浏览器中保存 LDAP 密码。
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="directory-records">标准化成员 JSON</FieldLabel>
|
||||||
|
<Textarea
|
||||||
|
id="directory-records"
|
||||||
|
className="min-h-64 font-mono text-xs"
|
||||||
|
value={directoryJson}
|
||||||
|
onChange={(event) => setDirectoryJson(event.target.value)}
|
||||||
|
/>
|
||||||
|
<FieldDescription>单次最多 1000 条;同一 subject 重复同步只会更新原身份。</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
<Button type="submit" className="self-end" disabled={pending === 'sync'}>
|
||||||
|
<RefreshCw data-icon="inline-start" />
|
||||||
|
{pending === 'sync' ? '同步中' : '开始目录同步'}
|
||||||
|
</Button>
|
||||||
|
{syncRun ? (
|
||||||
|
<Alert variant={syncRun.failed_count ? 'destructive' : 'default'}>
|
||||||
|
<AlertTitle>同步运行 #{syncRun.id} · {syncRun.status}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
共 {syncRun.total_count} 条,新建 {syncRun.created_count},更新 {syncRun.updated_count},失败 {syncRun.failed_count}。
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : providers.length === 0 ? (
|
||||||
|
<AppEmptyState title="等待身份提供方" description="配置提供方后即可维护组织映射与目录同步。" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<IdentityProviderDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
provider={editingProvider}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
onSaved={loadProviders}
|
||||||
|
/>
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(mappingToDelete)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !pending?.startsWith('mapping-')) setMappingToDelete(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>删除组织映射?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
删除后,企业组织“{mappingToDelete?.external_group}”将不再同步到平台用户组。已有用户不会在本次操作中被修改。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={Boolean(pending?.startsWith('mapping-'))}>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
disabled={!mappingToDelete || Boolean(pending?.startsWith('mapping-'))}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (mappingToDelete) void removeMapping(mappingToDelete)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pending?.startsWith('mapping-') ? '删除中' : '确认删除'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</AppPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
import { apiRequest } from '@/lib/api'
|
import { apiRequest } from '@/lib/api'
|
||||||
|
|
||||||
import type { AuditLog, Permission, RoleDetail, SystemMenu, SystemUser, UserGroup } from './types'
|
import type {
|
||||||
|
AuditLog,
|
||||||
|
DirectorySyncRun,
|
||||||
|
EnterpriseGroupMapping,
|
||||||
|
IdentityProvider,
|
||||||
|
IdentityProviderInput,
|
||||||
|
Permission,
|
||||||
|
RoleDetail,
|
||||||
|
SystemMenu,
|
||||||
|
SystemUser,
|
||||||
|
UserGroup
|
||||||
|
} from './types'
|
||||||
|
|
||||||
export function listUsers() {
|
export function listUsers() {
|
||||||
return apiRequest<SystemUser[]>('/accounts/users')
|
return apiRequest<SystemUser[]>('/accounts/users')
|
||||||
@@ -138,3 +149,55 @@ export function updateMenu(
|
|||||||
export function listAuditLogs() {
|
export function listAuditLogs() {
|
||||||
return apiRequest<AuditLog[]>('/audit/operation-logs')
|
return apiRequest<AuditLog[]>('/audit/operation-logs')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listIdentityProviders() {
|
||||||
|
return apiRequest<IdentityProvider[]>('/identity/providers')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIdentityProvider(payload: IdentityProviderInput & { slug: string; provider_type: 'oidc' | 'ldap' }) {
|
||||||
|
return apiRequest<IdentityProvider>('/identity/providers', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateIdentityProvider(providerId: number, payload: Partial<IdentityProviderInput>) {
|
||||||
|
return apiRequest<IdentityProvider>(`/identity/providers/${providerId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEnterpriseGroupMappings(providerId: number) {
|
||||||
|
return apiRequest<EnterpriseGroupMapping[]>(`/identity/providers/${providerId}/group-mappings`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEnterpriseGroupMapping(providerId: number, externalGroup: string, userGroupId: number) {
|
||||||
|
return apiRequest<EnterpriseGroupMapping>(`/identity/providers/${providerId}/group-mappings`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ external_group: externalGroup, user_group_id: userGroupId })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteEnterpriseGroupMapping(mappingId: number) {
|
||||||
|
return apiRequest<{ status: string }>(`/identity/group-mappings/${mappingId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncEnterpriseDirectory(
|
||||||
|
providerId: number,
|
||||||
|
records: Array<{
|
||||||
|
subject: string
|
||||||
|
username?: string
|
||||||
|
email?: string
|
||||||
|
display_name?: string
|
||||||
|
active?: boolean
|
||||||
|
groups?: string[]
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
return apiRequest<DirectorySyncRun>(`/identity/providers/${providerId}/directory-sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ records })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,3 +61,68 @@ export type AuditLog = {
|
|||||||
user_agent: string
|
user_agent: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IdentityProvider = {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
provider_type: 'oidc' | 'ldap'
|
||||||
|
enabled: boolean
|
||||||
|
button_label: string
|
||||||
|
issuer: string
|
||||||
|
metadata_url: string
|
||||||
|
client_id: string
|
||||||
|
credential_set: boolean
|
||||||
|
credential_masked: string
|
||||||
|
scopes: string[]
|
||||||
|
claim_mappings: Record<string, string>
|
||||||
|
configuration: Record<string, unknown>
|
||||||
|
auto_create_users: boolean
|
||||||
|
sync_profile: boolean
|
||||||
|
sync_groups: boolean
|
||||||
|
identity_count: number
|
||||||
|
group_mapping_count: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IdentityProviderInput = {
|
||||||
|
slug?: string
|
||||||
|
name: string
|
||||||
|
provider_type?: 'oidc' | 'ldap'
|
||||||
|
enabled: boolean
|
||||||
|
button_label: string
|
||||||
|
issuer: string
|
||||||
|
metadata_url: string
|
||||||
|
client_id: string
|
||||||
|
credential?: string
|
||||||
|
scopes: string[]
|
||||||
|
claim_mappings: Record<string, string>
|
||||||
|
configuration: Record<string, unknown>
|
||||||
|
auto_create_users: boolean
|
||||||
|
sync_profile: boolean
|
||||||
|
sync_groups: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EnterpriseGroupMapping = {
|
||||||
|
id: number
|
||||||
|
provider_id: number
|
||||||
|
external_group: string
|
||||||
|
user_group_id: number
|
||||||
|
user_group_name: string
|
||||||
|
user_group_code: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DirectorySyncRun = {
|
||||||
|
id: number
|
||||||
|
provider_id: number
|
||||||
|
status: 'completed' | 'partial' | 'failed'
|
||||||
|
total_count: number
|
||||||
|
created_count: number
|
||||||
|
updated_count: number
|
||||||
|
failed_count: number
|
||||||
|
errors: Array<{ index: string; subject: string; error: string }>
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api'
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api'
|
||||||
|
|
||||||
|
export function apiUrl(path: string) {
|
||||||
|
return `${API_BASE_URL}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
let csrfToken: string | null = null
|
let csrfToken: string | null = null
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -29,7 +33,7 @@ async function readError(response: Response) {
|
|||||||
|
|
||||||
async function getCsrfToken() {
|
async function getCsrfToken() {
|
||||||
if (!csrfToken) {
|
if (!csrfToken) {
|
||||||
const response = await fetch(`${API_BASE_URL}/accounts/auth/csrf`, {
|
const response = await fetch(apiUrl('/accounts/auth/csrf'), {
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -51,7 +55,7 @@ export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T
|
|||||||
headers.set('x-csrftoken', await getCsrfToken())
|
headers.set('x-csrftoken', await getCsrfToken())
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
const response = await fetch(apiUrl(path), {
|
||||||
...init,
|
...init,
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers
|
headers
|
||||||
|
|||||||
Reference in New Issue
Block a user