diff --git a/backend/apps/accounts/identity_api.py b/backend/apps/accounts/identity_api.py new file mode 100644 index 0000000..ed45c8f --- /dev/null +++ b/backend/apps/accounts/identity_api.py @@ -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 diff --git a/backend/apps/accounts/identity_schemas.py b/backend/apps/accounts/identity_schemas.py new file mode 100644 index 0000000..cd40ef1 --- /dev/null +++ b/backend/apps/accounts/identity_schemas.py @@ -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 diff --git a/backend/apps/accounts/identity_services.py b/backend/apps/accounts/identity_services.py new file mode 100644 index 0000000..af1000a --- /dev/null +++ b/backend/apps/accounts/identity_services.py @@ -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 diff --git a/backend/apps/accounts/menus.py b/backend/apps/accounts/menus.py index 6017663..a02e626 100644 --- a/backend/apps/accounts/menus.py +++ b/backend/apps/accounts/menus.py @@ -102,6 +102,24 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = ( 40, "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.users", "用户管理", "#/system/users", "users", "system", 10, "system.user.view" @@ -121,6 +139,15 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = ( MenuSpec( "system.menus", "菜单权限", "#/system/menus", "menu", "system", 40, "system.menu.view" ), - MenuSpec("system.audit", "操作审计", "#/audit", "audit", "system", 50, "system.audit.view"), - MenuSpec("system.design", "设计系统", "#/design-system", "settings", "system", 60), + MenuSpec( + "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), ) diff --git a/backend/apps/accounts/migrations/0003_enterpriseidentityprovider_enterprisegroupmapping_and_more.py b/backend/apps/accounts/migrations/0003_enterpriseidentityprovider_enterprisegroupmapping_and_more.py new file mode 100644 index 0000000..bd31f8a --- /dev/null +++ b/backend/apps/accounts/migrations/0003_enterpriseidentityprovider_enterprisegroupmapping_and_more.py @@ -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'), + ), + ] diff --git a/backend/apps/accounts/migrations/0004_add_enterprise_identity_menu.py b/backend/apps/accounts/migrations/0004_add_enterprise_identity_menu.py new file mode 100644 index 0000000..98fa338 --- /dev/null +++ b/backend/apps/accounts/migrations/0004_add_enterprise_identity_menu.py @@ -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), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 2bcdd60..99a4e5b 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -44,3 +44,148 @@ class MenuItem(models.Model): def __str__(self) -> str: 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"), + ] diff --git a/backend/apps/accounts/oidc.py b/backend/apps/accounts/oidc.py new file mode 100644 index 0000000..58b512c --- /dev/null +++ b/backend/apps/accounts/oidc.py @@ -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)}" diff --git a/backend/apps/accounts/tests/test_enterprise_identity.py b/backend/apps/accounts/tests/test_enterprise_identity.py new file mode 100644 index 0000000..089ccb9 --- /dev/null +++ b/backend/apps/accounts/tests/test_enterprise_identity.py @@ -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 + ) diff --git a/backend/apps/channels/__init__.py b/backend/apps/channels/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/channels/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/channels/api.py b/backend/apps/channels/api.py new file mode 100644 index 0000000..4a511dd --- /dev/null +++ b/backend/apps/channels/api.py @@ -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} diff --git a/backend/apps/channels/apps.py b/backend/apps/channels/apps.py new file mode 100644 index 0000000..9043185 --- /dev/null +++ b/backend/apps/channels/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ChannelsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.channels" diff --git a/backend/apps/channels/migrations/0001_initial.py b/backend/apps/channels/migrations/0001_initial.py new file mode 100644 index 0000000..ffee76c --- /dev/null +++ b/backend/apps/channels/migrations/0001_initial.py @@ -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"), + ), + ] diff --git a/backend/apps/channels/migrations/0002_channelmessage_customer_reply.py b/backend/apps/channels/migrations/0002_channelmessage_customer_reply.py new file mode 100644 index 0000000..5594dcd --- /dev/null +++ b/backend/apps/channels/migrations/0002_channelmessage_customer_reply.py @@ -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", + ), + ), + ] diff --git a/backend/apps/channels/migrations/__init__.py b/backend/apps/channels/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/channels/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/channels/models.py b/backend/apps/channels/models.py new file mode 100644 index 0000000..23d7a93 --- /dev/null +++ b/backend/apps/channels/models.py @@ -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", + ) + ] diff --git a/backend/apps/channels/schemas.py b/backend/apps/channels/schemas.py new file mode 100644 index 0000000..46fe01a --- /dev/null +++ b/backend/apps/channels/schemas.py @@ -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 diff --git a/backend/apps/channels/services.py b/backend/apps/channels/services.py new file mode 100644 index 0000000..e3433b6 --- /dev/null +++ b/backend/apps/channels/services.py @@ -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 diff --git a/backend/apps/channels/tasks.py b/backend/apps/channels/tasks.py new file mode 100644 index 0000000..f0c41ec --- /dev/null +++ b/backend/apps/channels/tasks.py @@ -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 diff --git a/backend/apps/channels/tests/__init__.py b/backend/apps/channels/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/channels/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/channels/tests/test_channel_runtime.py b/backend/apps/channels/tests/test_channel_runtime.py new file mode 100644 index 0000000..adef6a4 --- /dev/null +++ b/backend/apps/channels/tests/test_channel_runtime.py @@ -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 diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index cddc5c9..a6924af 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -1,7 +1,10 @@ import os from pathlib import Path +from dotenv import load_dotenv + 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") DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true" @@ -13,17 +16,21 @@ INSTALLED_APPS = [ "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", + "django.contrib.postgres", "django.contrib.staticfiles", "corsheaders", "apps.accounts", "apps.audit", "apps.system", "apps.tickets", + "apps.workitems", "apps.solutions", "apps.knowledge", "apps.modelhub", "apps.skills", "apps.agents", + "apps.tools", + "apps.channels", ] MIDDLEWARE = [ @@ -102,6 +109,8 @@ CSRF_TRUSTED_ORIGINS = os.getenv( "CSRF_TRUSTED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173", ).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_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") 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_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_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_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", os.getenv("MINIO_ROOT_USER", "minio")) diff --git a/backend/config/settings/test.py b/backend/config/settings/test.py index c8bc914..371c7a3 100644 --- a/backend/config/settings/test.py +++ b/backend/config/settings/test.py @@ -7,6 +7,8 @@ DATABASES = { } } +KNOWLEDGE_EMBEDDING_ALLOW_HASH_FALLBACK = True + PASSWORD_HASHERS = [ "django.contrib.auth.hashers.MD5PasswordHasher", ] @@ -14,3 +16,4 @@ PASSWORD_HASHERS = [ AI_CREDENTIAL_ENCRYPTION_KEY = "t8XuQv9e8WDoFv7RXT4-zdw1hQChGFKtr7E6ccRZAzU=" CELERY_TASK_ALWAYS_EAGER = True CELERY_TASK_EAGER_PROPAGATES = True +TICKET_MATERIAL_SCANNER_BACKEND = "test" diff --git a/backend/config/urls.py b/backend/config/urls.py index af6f329..6f67749 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -3,9 +3,11 @@ from django.urls import path from ninja import NinjaAPI 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 runs_router as agent_runs_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.modelhub.api import router as modelhub_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.tickets.api import router as tickets_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.add_router("/accounts", accounts_router) +api.add_router("/identity", identity_router) api.add_router("/system", system_router) api.add_router("/audit", audit_router) api.add_router("/tickets", tickets_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("/knowledge", knowledge_router) api.add_router("/modelhub", modelhub_router) api.add_router("/skills", skills_router) api.add_router("/agents", agents_router) api.add_router("/agent-runs", agent_runs_router) +api.add_router("/tools", tools_router) +api.add_router("/channels", channels_router) urlpatterns = [ + path("internal-mcp", internal_mcp_view), path("admin/", admin.site.urls), path("api/", api.urls), ] diff --git a/frontend/src/app/route-meta.tsx b/frontend/src/app/route-meta.tsx index 482e834..e1aa764 100644 --- a/frontend/src/app/route-meta.tsx +++ b/frontend/src/app/route-meta.tsx @@ -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 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 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 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 }))) @@ -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 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 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 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 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 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 }))) @@ -53,11 +57,12 @@ const ProfilePage = React.lazy(() => import('@/features/profile/ProfilePage').th export const routeMeta: RouteMeta[] = [ { key: 'workbench', pattern: /^\/$/, title: '工作台', description: '聚合待办、风险与最近处理进展', section: '工作台', navPath: '/', breadcrumbs: [], agent: 'docked', render: () => }, { key: 'profile', pattern: /^\/profile$/, title: '个人中心', description: '查看个人工作情况并维护账号设置', section: '个人中心', navPath: '/profile', breadcrumbs: [{ label: '显示与账户' }], agent: 'on-demand', render: () => }, - { key: 'ticket-create', pattern: /^\/tickets\/new$/, title: '创建工单', description: '记录问题上下文并进入支持流程', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], agent: 'docked', render: () => }, + { key: 'ticket-create', pattern: /^\/tickets\/new(?:\?.*)?$/, title: '创建工单', description: '记录问题上下文并进入支持流程', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], agent: 'docked', render: () => }, { key: 'ticket-detail', pattern: /^\/tickets\/(\d+)$/, title: '工单详情', description: '处理状态、方案与协作记录', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], backPath: '/tickets', agent: 'docked', render: (match) => }, { key: 'tickets', pattern: /^\/tickets(?:\?.*)?$/, title: '工单中心', description: '统一查看、筛选和推进支持工单', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单' }], agent: 'docked', render: () => }, { key: 'knowledge-search', pattern: /^\/knowledge\/search$/, title: '知识检索', description: '跨知识条目与解决方案检索处理依据', section: '知识与方案', navPath: '/knowledge/search', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => }, { key: 'knowledge-create', pattern: /^\/knowledge\/items\/new$/, title: '新建知识条目', description: '沉淀可复用的支持知识', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => }, + { key: 'knowledge-governance', pattern: /^\/knowledge\/governance$/, title: '知识治理', description: '审核资产、追踪版本与查看影响范围', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => }, { key: 'knowledge', pattern: /^\/knowledge$/, title: '知识库', description: '集中管理业务知识、文档与检索范围', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => }, { key: 'solution-create', pattern: /^\/solutions\/new(?:\?.*)?$/, title: '新建解决方案', description: '将验证过的处理过程沉淀为方案', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '解决方案库', path: '/solutions' }], backPath: '/solutions', agent: 'docked', render: () => }, { key: 'solutions', pattern: /^\/solutions$/, title: '解决方案库', description: '复用已验证的处置流程和回复依据', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => }, @@ -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: () => }, { 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) => }, { key: 'agent-runs', pattern: /^\/agent-runs$/, title: 'Agent 运行记录', description: '追踪智能处理任务的执行结果', section: 'AI 平台', navPath: '/agent-runs', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => }, + { key: 'tools', pattern: /^\/tools$/, title: '工具治理', description: '统一管理 Tool、MCP、授权和调用风险', section: 'AI 平台', navPath: '/tools', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => }, + { key: 'channels', pattern: /^\/channels$/, title: '渠道接入', description: '管理服务群消息的幂等收发与回执', section: 'AI 平台', navPath: '/channels', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => }, { key: 'system-users', pattern: /^\/system\/users$/, title: '用户管理', description: '维护平台账号与可用状态', section: '系统治理', navPath: '/system/users', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => }, { key: 'system-groups', pattern: /^\/system\/user-groups$/, title: '用户组管理', description: '按组织和职责管理授权范围', section: '系统治理', navPath: '/system/user-groups', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => }, { key: 'system-roles', pattern: /^\/system\/roles$/, title: '角色管理', description: '维护角色与权限集合', section: '系统治理', navPath: '/system/roles', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => }, { key: 'system-menus', pattern: /^\/system\/menus$/, title: '菜单权限', description: '管理导航可见性和功能权限', section: '系统治理', navPath: '/system/menus', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => }, + { key: 'system-identity', pattern: /^\/system\/identity$/, title: '企业身份', description: '管理单点登录、外部身份与组织同步', section: '系统治理', navPath: '/system/identity', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => }, { key: 'audit', pattern: /^\/audit$/, title: '审计日志', description: '追踪关键配置与业务操作记录', section: '系统治理', navPath: '/audit', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => }, { key: 'design-system', pattern: /^\/design-system$/, title: '设计系统预览', description: '检查基础组件和语义状态', section: '系统治理', navPath: '/design-system', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => }, ] diff --git a/frontend/src/components/app/AppCreateDialog.tsx b/frontend/src/components/app/AppCreateDialog.tsx index 6e95d8a..61ef705 100644 --- a/frontend/src/components/app/AppCreateDialog.tsx +++ b/frontend/src/components/app/AppCreateDialog.tsx @@ -22,6 +22,7 @@ type AppCreateDialogProps = { onSubmit: React.FormEventHandler children: React.ReactNode pending?: boolean + pendingLabel?: string submitDisabled?: boolean error?: string className?: string @@ -37,6 +38,7 @@ export function AppCreateDialog({ onSubmit, children, pending = false, + pendingLabel = '正在创建…', submitDisabled = false, error, className, @@ -69,7 +71,7 @@ export function AppCreateDialog({ diff --git a/frontend/src/components/app/AppRouteTabs.test.tsx b/frontend/src/components/app/AppRouteTabs.test.tsx index 74a51c6..df608ef 100644 --- a/frontend/src/components/app/AppRouteTabs.test.tsx +++ b/frontend/src/components/app/AppRouteTabs.test.tsx @@ -12,11 +12,19 @@ function navigate(path: string) { } beforeEach(() => { + vi.stubGlobal('ResizeObserver', class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} + }) window.sessionStorage.clear() window.location.hash = '#/tickets/42' }) -afterEach(() => cleanup()) +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) describe('已打开页面标签', () => { it('只显示实际打开过的页面,并允许关闭非当前标签', async () => { @@ -64,6 +72,47 @@ describe('已打开页面标签', () => { expect(screen.getByRole('tab', { name: '知识库' })).toBeInTheDocument() }) + it('右键标签可以关闭其左侧和右侧标签', async () => { + const user = userEvent.setup() + render() + 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() + 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 () => { const user = userEvent.setup() const { container } = render() diff --git a/frontend/src/components/app/AppRouteTabs.tsx b/frontend/src/components/app/AppRouteTabs.tsx index 721d45d..f42eebd 100644 --- a/frontend/src/components/app/AppRouteTabs.tsx +++ b/frontend/src/components/app/AppRouteTabs.tsx @@ -1,5 +1,5 @@ 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 { Button } from '@/components/ui/button' @@ -9,14 +9,26 @@ import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger } 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' const STORAGE_KEY = 'ai-ticket-platform.opened-route-tabs.v2' const MAX_OPEN_TABS = 12 const SCROLL_EPSILON = 2 +const WORKBENCH_TAB: RouteNavigationTab = { label: '工作台', path: '/' } + +type CloseScope = 'left' | 'right' | 'others' | 'all' type TabScrollState = { overflow: boolean @@ -60,6 +72,7 @@ export function AppRouteTabs() { canScrollLeft: false, canScrollRight: false }) + const activeTabIndex = openedTabs.findIndex((tab) => tab.path === route) const syncScrollState = React.useCallback(() => { const element = scrollAreaRef.current @@ -124,13 +137,33 @@ export function AppRouteTabs() { const closeTab = (path: string) => { 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) setOpenedTabs(next) if (path !== route) return - const fallback = next[Math.max(0, closedIndex - 1)] ?? next[0] - window.location.hash = fallback?.path ?? '/' + const fallback = remaining[Math.max(0, closedIndex - 1)] ?? remaining[0] ?? WORKBENCH_TAB + 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) => { @@ -175,39 +208,53 @@ export function AppRouteTabs() { event.currentTarget.scrollLeft += event.deltaY }} > - {openedTabs.map((tab) => ( -
{ - if (node) tabRefs.current.set(tab.path, node) - else tabRefs.current.delete(tab.path) - }} - className="group/tab relative flex flex-none items-center" - > - - {tab.label} - - -
+ {openedTabs.map((tab, tabIndex) => ( + + +
{ + if (node) tabRefs.current.set(tab.path, node) + else tabRefs.current.delete(tab.path) + }} + className="group/tab relative flex flex-none items-center" + > + + {tab.label} + + +
+
+ + + closeTab(tab.path)}>关闭当前标签 + closeTabs(tab.path, 'left')}>关闭左侧标签 + closeTabs(tab.path, 'right')}>关闭右侧标签 + closeTabs(tab.path, 'others')}>关闭其他标签 + + + + closeTabs(tab.path, 'all')}>关闭全部标签 + + +
))} {scrollState.overflow ? ( - <> - - - - - - 已打开页面 · {openedTabs.length} - - {openedTabs.map((tab) => ( - { - if (tab.path !== route) window.location.hash = tab.path - }} - > - {tab.path === route ? : - ))} - - - - ) : null} + + + + + + + 已打开页面 · {openedTabs.length} + {openedTabs.map((tab) => ( + { + if (tab.path !== route) window.location.hash = tab.path + }} + > + {tab.path === route ? : + ))} + + + + 批量关闭 + closeTabs(route, 'left')}>关闭左侧标签 + closeTabs(route, 'right')}>关闭右侧标签 + closeTabs(route, 'others')}>关闭其他标签 + closeTabs(route, 'all')}>关闭全部标签 + + + ) } diff --git a/frontend/src/components/app/AppSidebar.tsx b/frontend/src/components/app/AppSidebar.tsx index 59d00c0..b8c9736 100644 --- a/frontend/src/components/app/AppSidebar.tsx +++ b/frontend/src/components/app/AppSidebar.tsx @@ -10,6 +10,7 @@ import { Library, LogOut, Menu, + MessageSquareMore, ScrollText, Search, Settings, @@ -56,6 +57,7 @@ const iconMap = { home: Home, library: Library, menu: Menu, + message: MessageSquareMore, roles: UserCog, search: Search, settings: Settings, diff --git a/frontend/src/features/auth/LoginPage.test.tsx b/frontend/src/features/auth/LoginPage.test.tsx new file mode 100644 index 0000000..2cac909 --- /dev/null +++ b/frontend/src/features/auth/LoginPage.test.tsx @@ -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() + + 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() + + await waitFor(() => expect(screen.getByText('企业登录失败')).toBeInTheDocument()) + expect(screen.getByPlaceholderText('请输入账号')).toBeInTheDocument() + expect(screen.getByPlaceholderText('请输入密码')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx index fcbec42..5757b73 100644 --- a/frontend/src/features/auth/LoginPage.tsx +++ b/frontend/src/features/auth/LoginPage.tsx @@ -1,13 +1,15 @@ 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' +import { FieldSeparator } from '@/components/ui/field' import { useAuth } from './AuthProvider' +import { listIdentityLoginProviders, startIdentityLogin, type IdentityLoginProvider } from './api' export function LoginPage() { const { login } = useAuth() @@ -15,6 +17,14 @@ export function LoginPage() { const [password, setPassword] = React.useState('') const [error, setError] = React.useState('') const [submitting, setSubmitting] = React.useState(false) + const [providers, setProviders] = React.useState([]) + + 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) { event.preventDefault() @@ -43,6 +53,24 @@ export function LoginPage() {
{error ? 无法登录{error} : null} + {providers.length ? ( +
+ {providers.map((provider) => ( + + ))} + 或使用本地账号 +
+ ) : null}
setUsername(event.target.value)} placeholder="请输入账号" />
setPassword(event.target.value)} placeholder="请输入密码" />
diff --git a/frontend/src/features/auth/api.ts b/frontend/src/features/auth/api.ts index 071d36d..e7649ce 100644 --- a/frontend/src/features/auth/api.ts +++ b/frontend/src/features/auth/api.ts @@ -1,7 +1,14 @@ -import { apiRequest } from '@/lib/api' +import { apiRequest, apiUrl } from '@/lib/api' import type { CurrentUser } from './types' +export type IdentityLoginProvider = { + slug: string + name: string + button_label: string + provider_type: 'oidc' +} + export async function getCurrentUser() { return apiRequest('/accounts/auth/me') } @@ -32,3 +39,12 @@ export async function logout() { method: 'POST' }) } + +export function listIdentityLoginProviders() { + return apiRequest('/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}`)) +} diff --git a/frontend/src/features/channels/ChannelManagementPage.test.tsx b/frontend/src/features/channels/ChannelManagementPage.test.tsx new file mode 100644 index 0000000..2394506 --- /dev/null +++ b/frontend/src/features/channels/ChannelManagementPage.test.tsx @@ -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() + + 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() + + 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() + }) +}) diff --git a/frontend/src/features/channels/ChannelManagementPage.tsx b/frontend/src/features/channels/ChannelManagementPage.tsx new file mode 100644 index 0000000..a14cd5c --- /dev/null +++ b/frontend/src/features/channels/ChannelManagementPage.tsx @@ -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 = { + 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([]) + const [messages, setMessages] = React.useState([]) + 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, 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) { + 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[] = [ + { + key: 'direction', + header: '方向', + cell: (message) => ( + + {message.direction === 'inbound' ? : } + {message.direction === 'inbound' ? '入站' : '出站'} + + ) + }, + { + key: 'channel', + header: '渠道 / 会话', + cell: (message) => ( +
+

{message.endpoint_name}

+

+ {message.external_conversation_id} +

+
+ ) + }, + { + key: 'message', + header: '消息', + cell: (message) => ( +
+

{message.text}

+

+ {message.sender || '平台'}{message.ticket_id ? ` · 工单 #${message.ticket_id}` : ''} + {message.customer_reply_task_id + ? ` · 回复任务 #${message.customer_reply_task_id} v${message.reply_revision}` + : ''} +

+
+ ) + }, + { + key: 'status', + header: '状态', + cell: (message) => ( +
+ {STATUS_META[message.status].label} + {message.error_message ? ( +

+ {message.error_message} +

+ ) : null} +
+ ) + }, + { + key: 'time', + header: '时间', + cell: (message) => ( + + {formatDateTime(message.occurred_at || message.created_at)} + + ) + }, + { + key: 'action', + header: '', + cell: (message) => message.direction === 'outbound' && message.status === 'failed' && canSend ? ( + + ) : null + } + ] + + if (!hasPermission('channel.view')) { + return ( + }> + + + 没有查看权限 + 请联系管理员授予“查看渠道消息”权限。 + + + ) + } + + 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 ( + Generic Webhook} + actions={( + <> + + {canManage ? ( + + ) : null} + + )} + /> + )} + > +
+ {metricCards.map(({ label, value, icon: Icon }) => ( + + + {label} +
+ {value} + +
+
+
+ ))} +
+ + {error ? ( + + + 渠道数据加载失败 + {error} + + ) : null} + + {loading ? ( +
+ ) : ( + + + 消息时间线 + 适配器 + + + + message.public_id} + emptyTitle="暂无渠道消息" + emptyDescription="签名 Webhook 收到消息后会显示在这里。" + toolbar={( +
+ + +
+ )} + /> +
+
+ +
+ {endpoints.length === 0 ? ( +
+ +
+ ) : endpoints.map((endpoint) => { + const webhookUrl = `${window.location.origin}/api/channels/webhooks/${endpoint.public_id}/messages` + return ( + + +
+
+ {endpoint.name} + {endpoint.adapter_type} +
+ + {endpoint.enabled ? '已启用' : '已停用'} + +
+
+ +
+

入站 Webhook

+
+ {webhookUrl} + +
+
+
+ 签名密钥:{endpoint.webhook_secret_set ? '已配置' : '未配置'} + 出站凭证:{endpoint.outbound_token_set ? '已配置' : '未配置'} +
+

+ 出站地址:{endpoint.outbound_url || '未配置'} +

+ {canManage ? ( +
+ 接收与发送 + void runAction( + `endpoint-${endpoint.id}`, + () => updateChannelEndpoint(endpoint.id, { enabled }), + enabled ? '渠道已启用' : '渠道已停用' + )} + /> +
+ ) : null} +
+
+ ) + })} +
+
+
+ )} + + + + + setEndpointForm({ ...endpointForm, name: event.target.value })} placeholder="例如:一线服务群" /> + + + setEndpointForm({ ...endpointForm, outbound_url: event.target.value })} placeholder="https://channel.example/messages" /> + + + setEndpointForm({ ...endpointForm, webhook_secret: event.target.value })} /> + + + setEndpointForm({ ...endpointForm, outbound_token: event.target.value })} /> + + + + +
+ ) +} diff --git a/frontend/src/features/channels/ChannelReplyDialog.test.tsx b/frontend/src/features/channels/ChannelReplyDialog.test.tsx new file mode 100644 index 0000000..e7d2537 --- /dev/null +++ b/frontend/src/features/channels/ChannelReplyDialog.test.tsx @@ -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( + + ) + + 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) + }) +}) diff --git a/frontend/src/features/channels/ChannelReplyDialog.tsx b/frontend/src/features/channels/ChannelReplyDialog.tsx new file mode 100644 index 0000000..5095dc4 --- /dev/null +++ b/frontend/src/features/channels/ChannelReplyDialog.tsx @@ -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 +}) { + const [endpoints, setEndpoints] = React.useState([]) + 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) { + 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 ( + + + + 通过渠道发送客户回复 + + 发送后将冻结当前回复版本;只有渠道送达回执会自动确认客户已收到。 + + + {task ? ( + + + + + 即将发送回复任务 #{task.id} + + 冻结版本 v{task.revision} + {task.final_text} + + + + + + + setConversationId(event.target.value)} + placeholder="例如:group-20260727-001" + /> + + {!loading && endpoints.length === 0 ? ( + + + 没有可用渠道 + 请先在“渠道接入”中创建并启用适配器。 + + ) : null} + {error ? ( + + + 渠道发送未完成 + {error} + + ) : null} + + + ) : null} + + + + + + + ) +} diff --git a/frontend/src/features/channels/api.ts b/frontend/src/features/channels/api.ts new file mode 100644 index 0000000..97c3255 --- /dev/null +++ b/frontend/src/features/channels/api.ts @@ -0,0 +1,68 @@ +import { apiRequest } from '@/lib/api' + +import type { ChannelEndpoint, ChannelMessage } from './types' + +export function listChannelEndpoints() { + return apiRequest('/channels/endpoints') +} + +export function createChannelEndpoint(payload: { + name: string + outbound_url: string + webhook_secret: string + outbound_token: string + enabled: boolean +}) { + return apiRequest('/channels/endpoints', { + method: 'POST', + body: JSON.stringify({ ...payload, adapter_type: 'generic_webhook' }) + }) +} + +export function updateChannelEndpoint( + endpointId: number, + payload: Partial> & { + webhook_secret?: string + outbound_token?: string + } +) { + return apiRequest(`/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(`/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 + } +) { + return apiRequest(`/channels/endpoints/${endpointId}/messages`, { + method: 'POST', + body: JSON.stringify(payload) + }) +} + +export function retryChannelMessage(publicId: string) { + return apiRequest(`/channels/messages/${publicId}/retry`, { + method: 'POST' + }) +} diff --git a/frontend/src/features/channels/types.ts b/frontend/src/features/channels/types.ts new file mode 100644 index 0000000..cd2f78e --- /dev/null +++ b/frontend/src/features/channels/types.ts @@ -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 + status: ChannelMessageStatus + error_message: string + occurred_at: string | null + sent_at: string | null + delivered_at: string | null + created_at: string + updated_at: string +} diff --git a/frontend/src/features/system/IdentityProviderDialog.tsx b/frontend/src/features/system/IdentityProviderDialog.tsx new file mode 100644 index 0000000..280c2c9 --- /dev/null +++ b/frontend/src/features/system/IdentityProviderDialog.tsx @@ -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 +} + +export function IdentityProviderDialog({ + open, + provider, + onOpenChange, + onSaved +}: IdentityProviderDialogProps) { + const [form, setForm] = React.useState(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) { + 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 ( + + + + {provider ? '编辑身份提供方' : '新增身份提供方'} + + 密钥只在保存时提交,页面与审计日志不会返回明文。 + + +
+
+ +
+ + 名称 + setForm({ ...form, name: event.target.value })} + placeholder="例如:公司统一身份" + required + /> + + + 稳定标识 + setForm({ ...form, slug: event.target.value })} + placeholder="corp-identity" + disabled={Boolean(provider)} + required + /> + +
+ + 协议类型 + + + LDAP 首批提供配置与目录同步适配边界;浏览器单点登录仅由 OIDC 提供。 + + + + {oidc ? ( + <> +
+ + Issuer + setForm({ ...form, issuer: event.target.value })} + placeholder="https://idp.example.com" + /> + + + 发现文档地址 + setForm({ ...form, metadata_url: event.target.value })} + placeholder="留空时由 Issuer 推导" + /> + +
+
+ + Client ID + setForm({ ...form, client_id: event.target.value })} + required + /> + + + + Client secret {provider?.credential_set ? `(${provider.credential_masked})` : ''} + + setForm({ ...form, credential: event.target.value })} + placeholder={provider?.credential_set ? '留空保留原密钥' : '输入密钥'} + required={!provider?.credential_set && form.enabled} + /> + +
+ + 登录按钮文案 + setForm({ ...form, button_label: event.target.value })} + placeholder={`使用 ${form.name || '企业账号'} 登录`} + /> + +
+ {[ + ['subject', 'Subject 声明'], + ['username', '账号声明'], + ['email', '邮箱声明'], + ['display_name', '显示名称声明'], + ['groups', '组织声明'] + ].map(([key, label]) => ( + + {label} + setClaim(key, event.target.value)} + required + /> + + ))} +
+ + ) : ( + <> + + LDAP 服务地址 + setForm({ ...form, server_uri: event.target.value })} + placeholder="ldaps://directory.example.com" + required={form.enabled} + /> + +
+ + Base DN + setForm({ ...form, base_dn: event.target.value })} /> + + + Bind DN + setForm({ ...form, bind_dn: event.target.value })} /> + +
+ + 用户过滤器 + setForm({ ...form, user_filter: event.target.value })} placeholder="(objectClass=person)" /> + + + + Bind password {provider?.credential_set ? `(${provider.credential_masked})` : ''} + + setForm({ ...form, credential: event.target.value })} + placeholder={provider?.credential_set ? '留空保留原密码' : '支持匿名绑定时可留空'} + /> + + + )} + + +
+ 自动创建本地账号 + 首次识别到稳定外部身份时创建无本地密码的账号。 +
+ setForm({ ...form, auto_create_users: checked })} /> +
+ +
+ 同步姓名与邮箱 + 企业目录中的最新资料会覆盖对应本地字段。 +
+ setForm({ ...form, sync_profile: checked })} /> +
+ +
+ 同步组织映射 + 只处理本提供方已登记的外部组织映射。 +
+ setForm({ ...form, sync_groups: checked })} /> +
+ +
+ 启用提供方 + {oidc ? '启用后会出现在登录页。' : '启用后允许目录适配器使用该配置。'} +
+ setForm({ ...form, enabled: checked })} /> +
+
+ {error ? ( + + + 保存未完成 + {error} + + ) : null} +
+ + + + +
+
+
+ ) +} diff --git a/frontend/src/features/system/SystemIdentityPage.test.tsx b/frontend/src/features/system/SystemIdentityPage.test.tsx new file mode 100644 index 0000000..197bf23 --- /dev/null +++ b/frontend/src/features/system/SystemIdentityPage.test.tsx @@ -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() + + 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() + 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() + }) +}) diff --git a/frontend/src/features/system/SystemIdentityPage.tsx b/frontend/src/features/system/SystemIdentityPage.tsx new file mode 100644 index 0000000..b1c1942 --- /dev/null +++ b/frontend/src/features/system/SystemIdentityPage.tsx @@ -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([]) + const [userGroups, setUserGroups] = React.useState([]) + const [selectedId, setSelectedId] = React.useState(null) + const [mappings, setMappings] = React.useState([]) + const [dialogOpen, setDialogOpen] = React.useState(false) + const [editingProvider, setEditingProvider] = React.useState(null) + const [externalGroup, setExternalGroup] = React.useState('') + const [userGroupId, setUserGroupId] = React.useState('') + const [directoryJson, setDirectoryJson] = React.useState(directorySample) + const [syncRun, setSyncRun] = React.useState(null) + const [mappingToDelete, setMappingToDelete] = React.useState(null) + const [pending, setPending] = React.useState(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) { + 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) { + 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[] = [ + { + key: 'provider', + header: '身份提供方', + cell: (provider) => ( +
+

{provider.name}

+

{provider.slug}

+
+ ) + }, + { + key: 'type', + header: '协议', + cell: (provider) => {provider.provider_type === 'oidc' ? 'OIDC' : 'LDAP'} + }, + { + key: 'counts', + header: '同步范围', + cell: (provider) => ( + + {provider.identity_count} 个身份 · {provider.group_mapping_count} 个映射 + + ) + }, + { + key: 'status', + header: '状态', + cell: (provider) => ( +
+ void toggleProvider(provider, checked)} + aria-label={`${provider.name}启用状态`} + /> + {provider.enabled ? '已启用' : '未启用'} +
+ ) + }, + { + key: 'actions', + header: '', + className: 'text-right', + cell: (provider) => ( + + ) + } + ] + + const mappingColumns: AppDataTableColumn[] = [ + { + key: 'external', + header: '企业组织标识', + cell: (mapping) => {mapping.external_group} + }, + { + key: 'local', + header: '平台用户组', + cell: (mapping) => ( +
+

{mapping.user_group_name}

+

{mapping.user_group_code}

+
+ ) + }, + { + key: 'created', + header: '创建时间', + cell: (mapping) => {formatDateTime(mapping.created_at)} + }, + { + key: 'actions', + header: '', + className: 'text-right', + cell: (mapping) => ( + + ) + } + ] + + return ( + { setEditingProvider(null); setDialogOpen(true) }}> + + 新增提供方 + + )} + /> + )} + > + {error ? ( + + 操作未完成 + {error} + + ) : null} + + provider.id} + emptyTitle="尚未配置企业身份" + emptyDescription="新增 OIDC 提供方后,启用的企业登录入口会出现在登录页。" + footer={

共 {providers.length} 个身份提供方

} + /> + + {selected ? ( + + +
+
+ 组织同步工作区 + 选择提供方,维护受控映射或通过目录适配器导入成员。 +
+ +
+
+ + + + 组织映射 + 目录同步 + + +
+ + + 企业组织标识 + setExternalGroup(event.target.value)} placeholder="例如:IT-Support" required /> + + + 平台用户组 + + + + +
+ mapping.id} + emptyTitle="暂无组织映射" + emptyDescription="没有映射的企业组织不会改变平台中的用户组归属。" + /> +
+ +
+ + + 目录适配器输入 + + 该入口接收已由可信连接器读取并标准化的成员记录;不会在浏览器中保存 LDAP 密码。 + + + + 标准化成员 JSON +