feat: add enterprise identity and channel integrations

This commit is contained in:
hailin
2026-07-27 19:03:42 +08:00
parent d93cb75c10
commit e69368a2f3
44 changed files with 5045 additions and 69 deletions

View File

@@ -0,0 +1,381 @@
from collections.abc import Mapping
from typing import Any
from urllib.parse import urlparse
from django.conf import settings
from django.contrib.auth import login
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.db.models import Count
from django.http import HttpResponseRedirect
from ninja import Router
from ninja.errors import HttpError
from apps.accounts.api import require_permission
from apps.accounts.identity_schemas import (
DirectorySyncIn,
DirectorySyncRunOut,
EnterpriseGroupMappingIn,
EnterpriseGroupMappingOut,
IdentityProviderCreateIn,
IdentityProviderOut,
IdentityProviderUpdateIn,
PublicIdentityProviderOut,
)
from apps.accounts.identity_services import (
IdentitySyncError,
run_directory_sync,
sync_identity_claims,
)
from apps.accounts.models import (
EnterpriseGroupMapping,
EnterpriseIdentityProvider,
UserGroup,
)
from apps.accounts.oidc import (
OIDCConfigurationError,
build_oidc_client,
callback_uri,
frontend_redirect,
normalize_return_to,
)
from apps.audit.models import OperationAuditLog
from apps.modelhub.crypto import (
CredentialEncryptionError,
encrypt_secret,
mask_secret,
)
router = Router(tags=["enterprise-identity"])
SENSITIVE_CONFIG_FRAGMENTS = ("password", "secret", "token", "credential")
def _audit(user, action: str, target_type: str, target_id: str, snapshot: dict, result="success"):
OperationAuditLog.objects.create(
user=user if getattr(user, "is_authenticated", False) else None,
module="identity",
action=action,
target_type=target_type,
target_id=target_id,
request_snapshot=snapshot,
result=result,
)
def _validate_configuration(configuration: Mapping[str, Any]) -> None:
def visit(value: Any) -> None:
if isinstance(value, Mapping):
for key, nested in value.items():
lowered = str(key).lower()
if any(fragment in lowered for fragment in SENSITIVE_CONFIG_FRAGMENTS):
raise HttpError(400, f"敏感配置 {key} 必须通过 credential 字段加密保存")
visit(nested)
elif isinstance(value, list):
for nested in value:
visit(nested)
visit(configuration)
def _validate_provider(provider: EnterpriseIdentityProvider) -> None:
if provider.provider_type == EnterpriseIdentityProvider.ProviderType.OIDC:
if not provider.metadata_url and not provider.issuer:
raise HttpError(400, "OIDC 提供方必须配置 metadata_url 或 issuer")
if not provider.client_id:
raise HttpError(400, "OIDC 提供方必须配置 client_id")
if "openid" not in (provider.scopes or []):
raise HttpError(400, "OIDC scopes 必须包含 openid")
if provider.enabled and not provider.credential_encrypted:
raise HttpError(400, "启用 OIDC 前必须配置 client secret")
for url in (provider.issuer, provider.metadata_url):
if not url:
continue
parsed = urlparse(url)
local_development = settings.DEBUG and parsed.hostname in {"localhost", "127.0.0.1"}
if parsed.scheme != "https" and not local_development:
raise HttpError(400, "OIDC 地址必须使用 HTTPS")
elif provider.provider_type == EnterpriseIdentityProvider.ProviderType.LDAP:
if provider.enabled and not provider.configuration.get("server_uri"):
raise HttpError(400, "启用 LDAP 前必须配置 server_uri")
else:
raise HttpError(400, "不支持的身份提供方类型")
def _serialize_provider(provider: EnterpriseIdentityProvider) -> dict:
return {
"id": provider.id,
"slug": provider.slug,
"name": provider.name,
"provider_type": provider.provider_type,
"enabled": provider.enabled,
"button_label": provider.button_label or f"使用 {provider.name} 登录",
"issuer": provider.issuer,
"metadata_url": provider.metadata_url,
"client_id": provider.client_id,
"credential_set": bool(provider.credential_encrypted),
"credential_masked": mask_secret(provider.credential_encrypted),
"scopes": provider.scopes,
"claim_mappings": provider.claim_mappings,
"configuration": provider.configuration,
"auto_create_users": provider.auto_create_users,
"sync_profile": provider.sync_profile,
"sync_groups": provider.sync_groups,
"identity_count": getattr(provider, "identity_count", 0),
"group_mapping_count": getattr(provider, "group_mapping_count", 0),
"created_at": provider.created_at,
"updated_at": provider.updated_at,
}
def _serialize_mapping(mapping: EnterpriseGroupMapping) -> dict:
return {
"id": mapping.id,
"provider_id": mapping.provider_id,
"external_group": mapping.external_group,
"user_group_id": mapping.user_group_id,
"user_group_name": mapping.user_group.name,
"user_group_code": mapping.user_group.code,
"created_at": mapping.created_at,
}
@router.get("/auth/providers", response=list[PublicIdentityProviderOut], auth=None)
def public_providers(request):
return [
{
"slug": provider.slug,
"name": provider.name,
"button_label": provider.button_label or f"使用 {provider.name} 登录",
"provider_type": provider.provider_type,
}
for provider in EnterpriseIdentityProvider.objects.filter(
enabled=True,
provider_type=EnterpriseIdentityProvider.ProviderType.OIDC,
)
]
@router.get("/auth/oidc/{slug}/start", auth=None)
def oidc_start(request, slug: str, return_to: str = "/"):
provider = EnterpriseIdentityProvider.objects.filter(
slug=slug,
enabled=True,
provider_type=EnterpriseIdentityProvider.ProviderType.OIDC,
).first()
if provider is None:
raise HttpError(404, "身份提供方不存在或未启用")
try:
client = build_oidc_client(provider)
request.session[f"identity_return_to:{provider.slug}"] = normalize_return_to(return_to)
return client.authorize_redirect(request, callback_uri(request, provider))
except (OIDCConfigurationError, CredentialEncryptionError) as error:
_audit(None, "oidc_start_failed", "identity_provider", str(provider.id), {}, "failed")
raise HttpError(503, str(error)) from error
@router.get("/auth/oidc/{slug}/callback", auth=None)
def oidc_callback(request, slug: str):
provider = EnterpriseIdentityProvider.objects.filter(
slug=slug,
enabled=True,
provider_type=EnterpriseIdentityProvider.ProviderType.OIDC,
).first()
if provider is None:
return HttpResponseRedirect(frontend_redirect(error="身份提供方不存在或未启用"))
try:
client = build_oidc_client(provider)
token = client.authorize_access_token(request)
claims = token.get("userinfo")
if not isinstance(claims, Mapping):
raise IdentitySyncError("OIDC 响应缺少已校验的用户身份声明")
identity, created = sync_identity_claims(provider, claims, login_event=True)
login(request, identity.user)
return_to = request.session.pop(f"identity_return_to:{provider.slug}", "/")
_audit(
identity.user,
"oidc_login",
"external_identity",
str(identity.id),
{"provider": provider.slug, "identity_created": created},
)
return HttpResponseRedirect(frontend_redirect(return_to))
except Exception as error:
_audit(
None,
"oidc_callback_failed",
"identity_provider",
str(provider.id),
{"error_type": type(error).__name__},
"failed",
)
return HttpResponseRedirect(frontend_redirect(error="企业登录失败,请联系管理员"))
@router.get("/providers", response=list[IdentityProviderOut])
def list_providers(request):
require_permission(request, "system.setting.manage")
queryset = EnterpriseIdentityProvider.objects.annotate(
identity_count=Count("external_identities", distinct=True),
group_mapping_count=Count("group_mappings", distinct=True),
)
return [_serialize_provider(provider) for provider in queryset]
@router.post("/providers", response=IdentityProviderOut)
def create_provider(request, payload: IdentityProviderCreateIn):
require_permission(request, "system.setting.manage")
values = payload.dict()
credential = values.pop("credential", "")
_validate_configuration(values["configuration"])
try:
with transaction.atomic():
provider = EnterpriseIdentityProvider(
**values,
credential_encrypted=encrypt_secret(credential) if credential else "",
created_by=request.user,
updated_by=request.user,
)
_validate_provider(provider)
provider.full_clean()
provider.save()
except CredentialEncryptionError as error:
raise HttpError(400, str(error)) from error
except ValidationError as error:
raise HttpError(400, "; ".join(error.messages)) from error
except IntegrityError as error:
raise HttpError(400, "身份提供方标识已存在") from error
_audit(
request.user,
"create_provider",
"identity_provider",
str(provider.id),
{"slug": provider.slug, "provider_type": provider.provider_type},
)
provider.identity_count = 0
provider.group_mapping_count = 0
return _serialize_provider(provider)
@router.patch("/providers/{provider_id}", response=IdentityProviderOut)
def update_provider(request, provider_id: int, payload: IdentityProviderUpdateIn):
require_permission(request, "system.setting.manage")
provider = EnterpriseIdentityProvider.objects.get(id=provider_id)
values = payload.dict(exclude_unset=True)
credential = values.pop("credential", None)
if "configuration" in values:
_validate_configuration(values["configuration"])
try:
if credential:
provider.credential_encrypted = encrypt_secret(credential)
for field, value in values.items():
setattr(provider, field, value)
provider.updated_by = request.user
_validate_provider(provider)
provider.full_clean()
provider.save()
except CredentialEncryptionError as error:
raise HttpError(400, str(error)) from error
except ValidationError as error:
raise HttpError(400, "; ".join(error.messages)) from error
_audit(
request.user,
"update_provider",
"identity_provider",
str(provider.id),
{"fields": sorted(values), "credential_replaced": bool(credential)},
)
provider.identity_count = provider.external_identities.count()
provider.group_mapping_count = provider.group_mappings.count()
return _serialize_provider(provider)
@router.get(
"/providers/{provider_id}/group-mappings",
response=list[EnterpriseGroupMappingOut],
)
def list_group_mappings(request, provider_id: int):
require_permission(request, "system.setting.manage")
mappings = EnterpriseGroupMapping.objects.select_related("user_group").filter(
provider_id=provider_id
)
return [_serialize_mapping(mapping) for mapping in mappings]
@router.post(
"/providers/{provider_id}/group-mappings",
response=EnterpriseGroupMappingOut,
)
def create_group_mapping(
request,
provider_id: int,
payload: EnterpriseGroupMappingIn,
):
require_permission(request, "system.setting.manage")
provider = EnterpriseIdentityProvider.objects.get(id=provider_id)
user_group = UserGroup.objects.get(id=payload.user_group_id)
external_group = payload.external_group.strip()
if not external_group:
raise HttpError(400, "企业组织标识不能为空")
try:
mapping = EnterpriseGroupMapping.objects.create(
provider=provider,
external_group=external_group,
user_group=user_group,
)
except IntegrityError as error:
raise HttpError(400, "该外部组织映射已存在") from error
_audit(
request.user,
"create_group_mapping",
"identity_provider",
str(provider.id),
{"external_group": mapping.external_group, "user_group_id": user_group.id},
)
return _serialize_mapping(mapping)
@router.delete("/group-mappings/{mapping_id}")
def delete_group_mapping(request, mapping_id: int):
require_permission(request, "system.setting.manage")
mapping = EnterpriseGroupMapping.objects.select_related("provider").get(id=mapping_id)
snapshot = {
"external_group": mapping.external_group,
"user_group_id": mapping.user_group_id,
}
provider_id = mapping.provider_id
mapping.delete()
_audit(
request.user,
"delete_group_mapping",
"identity_provider",
str(provider_id),
snapshot,
)
return {"status": "deleted"}
@router.post(
"/providers/{provider_id}/directory-sync",
response=DirectorySyncRunOut,
)
def directory_sync(request, provider_id: int, payload: DirectorySyncIn):
require_permission(request, "system.setting.manage")
if len(payload.records) > 1000:
raise HttpError(400, "单次目录同步最多接收 1000 条记录")
provider = EnterpriseIdentityProvider.objects.get(id=provider_id)
records = [record.dict() for record in payload.records]
run = run_directory_sync(provider, records, request.user)
_audit(
request.user,
"directory_sync",
"identity_provider",
str(provider.id),
{
"run_id": run.id,
"total_count": run.total_count,
"failed_count": run.failed_count,
},
"success" if run.failed_count == 0 else "failed",
)
return run

View File

@@ -0,0 +1,110 @@
from datetime import datetime
from typing import Any
from ninja import Schema
class PublicIdentityProviderOut(Schema):
slug: str
name: str
button_label: str
provider_type: str
class IdentityProviderOut(Schema):
id: int
slug: str
name: str
provider_type: str
enabled: bool
button_label: str
issuer: str
metadata_url: str
client_id: str
credential_set: bool
credential_masked: str
scopes: list[str]
claim_mappings: dict[str, str]
configuration: dict[str, Any]
auto_create_users: bool
sync_profile: bool
sync_groups: bool
identity_count: int
group_mapping_count: int
created_at: datetime
updated_at: datetime
class IdentityProviderCreateIn(Schema):
slug: str
name: str
provider_type: str = "oidc"
enabled: bool = False
button_label: str = ""
issuer: str = ""
metadata_url: str = ""
client_id: str = ""
credential: str = ""
scopes: list[str] = ["openid", "profile", "email"]
claim_mappings: dict[str, str] = {}
configuration: dict[str, Any] = {}
auto_create_users: bool = True
sync_profile: bool = True
sync_groups: bool = True
class IdentityProviderUpdateIn(Schema):
name: str | None = None
enabled: bool | None = None
button_label: str | None = None
issuer: str | None = None
metadata_url: str | None = None
client_id: str | None = None
credential: str | None = None
scopes: list[str] | None = None
claim_mappings: dict[str, str] | None = None
configuration: dict[str, Any] | None = None
auto_create_users: bool | None = None
sync_profile: bool | None = None
sync_groups: bool | None = None
class EnterpriseGroupMappingOut(Schema):
id: int
provider_id: int
external_group: str
user_group_id: int
user_group_name: str
user_group_code: str
created_at: datetime
class EnterpriseGroupMappingIn(Schema):
external_group: str
user_group_id: int
class DirectoryRecordIn(Schema):
subject: str
username: str = ""
email: str = ""
display_name: str = ""
active: bool = True
groups: list[str] = []
class DirectorySyncIn(Schema):
records: list[DirectoryRecordIn]
class DirectorySyncRunOut(Schema):
id: int
provider_id: int
status: str
total_count: int
created_count: int
updated_count: int
failed_count: int
errors: list[dict[str, Any]]
started_at: datetime
finished_at: datetime | None = None

View File

@@ -0,0 +1,230 @@
"""企业身份声明、目录账号和组织映射同步服务。"""
import re
from collections.abc import Mapping
from typing import Any
from django.contrib.auth import get_user_model
from django.db import IntegrityError, transaction
from django.utils import timezone
from apps.accounts.models import (
EnterpriseDirectorySyncRun,
EnterpriseIdentityProvider,
ExternalIdentity,
)
DEFAULT_CLAIM_MAPPINGS = {
"subject": "sub",
"username": "preferred_username",
"email": "email",
"display_name": "name",
"groups": "groups",
}
class IdentitySyncError(ValueError):
pass
def resolved_claim_mappings(provider: EnterpriseIdentityProvider) -> dict[str, str]:
return {**DEFAULT_CLAIM_MAPPINGS, **(provider.claim_mappings or {})}
def read_claim(claims: Mapping[str, Any], path: str, default: Any = "") -> Any:
value: Any = claims
for part in path.split("."):
if not isinstance(value, Mapping) or part not in value:
return default
value = value[part]
return value
def _clean_username(value: str) -> str:
cleaned = re.sub(r"[^@\w.+-]", "_", value.strip(), flags=re.UNICODE).strip("._")
return cleaned[:150]
def _unique_username(provider: EnterpriseIdentityProvider, preferred: str, subject: str) -> str:
user_model = get_user_model()
base = (
_clean_username(preferred)
or _clean_username(f"{provider.slug}_{subject}")
or provider.slug
)
if not user_model.objects.filter(username=base).exists():
return base
for suffix in range(2, 10000):
candidate = f"{base[: 150 - len(str(suffix)) - 1]}-{suffix}"
if not user_model.objects.filter(username=candidate).exists():
return candidate
raise IdentitySyncError("无法生成唯一的本地账号")
def _claim_list(value: Any) -> list[str]:
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
if isinstance(value, (list, tuple, set)):
return [str(item).strip() for item in value if str(item).strip()]
return []
def _sync_business_groups(
provider: EnterpriseIdentityProvider,
identity: ExternalIdentity,
external_groups: list[str],
) -> None:
mappings = list(provider.group_mappings.select_related("user_group"))
managed_group_ids = {mapping.user_group_id for mapping in mappings}
desired_group_ids = {
mapping.user_group_id
for mapping in mappings
if mapping.external_group in set(external_groups) and mapping.user_group.is_active
}
if managed_group_ids:
identity.user.business_groups.remove(*(managed_group_ids - desired_group_ids))
if desired_group_ids:
identity.user.business_groups.add(*desired_group_ids)
@transaction.atomic
def sync_identity_claims(
provider: EnterpriseIdentityProvider,
claims: Mapping[str, Any],
*,
login_event: bool = False,
active: bool | None = None,
claim_mappings: Mapping[str, str] | None = None,
) -> tuple[ExternalIdentity, bool]:
"""按稳定 subject 同步本地账号、档案和受管业务组,不依据显示名关联身份。"""
mappings = dict(claim_mappings or resolved_claim_mappings(provider))
subject = str(read_claim(claims, mappings["subject"], "")).strip()
if not subject:
raise IdentitySyncError("身份声明缺少稳定 subject")
identity = (
ExternalIdentity.objects.select_for_update()
.select_related("user")
.filter(provider=provider, subject=subject)
.first()
)
created = identity is None
email = str(read_claim(claims, mappings["email"], "") or "").strip().lower()
preferred_username = str(read_claim(claims, mappings["username"], "") or "").strip()
display_name = str(read_claim(claims, mappings["display_name"], "") or "").strip()
if identity is None:
if not provider.auto_create_users:
raise IdentitySyncError("该身份提供方不允许自动创建账号")
user_model = get_user_model()
user = None
if email and provider.configuration.get("allow_email_linking") is True:
user = user_model.objects.filter(email__iexact=email).order_by("id").first()
if user is None:
username = _unique_username(provider, preferred_username or email, subject)
user = user_model(username=username, email=email, first_name=display_name)
user.set_unusable_password()
user.save()
identity = ExternalIdentity.objects.create(
provider=provider,
subject=subject,
user=user,
)
user = identity.user
changed_fields: list[str] = []
if provider.sync_profile:
if email and user.email != email:
user.email = email
changed_fields.append("email")
if display_name and user.first_name != display_name:
user.first_name = display_name
changed_fields.append("first_name")
if active is not None and user.is_active != active:
user.is_active = active
changed_fields.append("is_active")
if changed_fields:
user.save(update_fields=changed_fields)
if login_event and not user.is_active:
raise IdentitySyncError("账号已停用")
now = timezone.now()
identity.claims_snapshot = dict(claims)
identity.last_synced_at = now
update_fields = ["claims_snapshot", "last_synced_at", "updated_at"]
if login_event:
identity.last_login_at = now
update_fields.append("last_login_at")
identity.save(update_fields=update_fields)
if provider.sync_groups:
_sync_business_groups(
provider,
identity,
_claim_list(read_claim(claims, mappings["groups"], [])),
)
return identity, created
def run_directory_sync(
provider: EnterpriseIdentityProvider,
records: list[dict[str, Any]],
initiated_by,
) -> EnterpriseDirectorySyncRun:
"""逐条同步目录快照,隔离单条失败并保留最多一百条脱敏错误摘要。"""
run = EnterpriseDirectorySyncRun.objects.create(
provider=provider,
initiated_by=initiated_by,
total_count=len(records),
)
errors: list[dict[str, str]] = []
for index, record in enumerate(records):
claims = {
"sub": record.get("subject", ""),
"preferred_username": record.get("username", ""),
"email": record.get("email", ""),
"name": record.get("display_name", ""),
"groups": record.get("groups", []),
}
try:
_, created = sync_identity_claims(
provider,
claims,
active=bool(record.get("active", True)),
claim_mappings=DEFAULT_CLAIM_MAPPINGS,
)
if created:
run.created_count += 1
else:
run.updated_count += 1
except (IdentitySyncError, IntegrityError) as error:
run.failed_count += 1
errors.append(
{
"index": str(index),
"subject": str(record.get("subject", ""))[:255],
"error": str(error),
}
)
run.errors = errors[:100]
if run.failed_count == 0:
run.status = EnterpriseDirectorySyncRun.Status.COMPLETED
elif run.failed_count == run.total_count:
run.status = EnterpriseDirectorySyncRun.Status.FAILED
else:
run.status = EnterpriseDirectorySyncRun.Status.PARTIAL
run.finished_at = timezone.now()
run.save(
update_fields=[
"created_count",
"updated_count",
"failed_count",
"errors",
"status",
"finished_at",
]
)
return run

View File

@@ -102,6 +102,24 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = (
40, 40,
"agent.run.view", "agent.run.view",
), ),
MenuSpec(
"platform.tools",
"Tool / MCP",
"#/tools",
"settings",
"platform",
50,
"tool.view",
),
MenuSpec(
"platform.channels",
"渠道接入",
"#/channels",
"message",
"platform",
60,
"channel.view",
),
MenuSpec("system", "系统治理", icon="shield", sort_order=50), MenuSpec("system", "系统治理", icon="shield", sort_order=50),
MenuSpec( MenuSpec(
"system.users", "用户管理", "#/system/users", "users", "system", 10, "system.user.view" "system.users", "用户管理", "#/system/users", "users", "system", 10, "system.user.view"
@@ -121,6 +139,15 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = (
MenuSpec( MenuSpec(
"system.menus", "菜单权限", "#/system/menus", "menu", "system", 40, "system.menu.view" "system.menus", "菜单权限", "#/system/menus", "menu", "system", 40, "system.menu.view"
), ),
MenuSpec("system.audit", "操作审计", "#/audit", "audit", "system", 50, "system.audit.view"), MenuSpec(
MenuSpec("system.design", "设计系统", "#/design-system", "settings", "system", 60), "system.identity",
"企业身份",
"#/system/identity",
"shield",
"system",
50,
"system.setting.manage",
),
MenuSpec("system.audit", "操作审计", "#/audit", "audit", "system", 60, "system.audit.view"),
MenuSpec("system.design", "设计系统", "#/design-system", "settings", "system", 70),
) )

View File

@@ -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'),
),
]

View File

@@ -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),
]

View File

@@ -44,3 +44,148 @@ class MenuItem(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return self.title return self.title
class EnterpriseIdentityProvider(models.Model):
class ProviderType(models.TextChoices):
OIDC = "oidc", "OpenID Connect"
LDAP = "ldap", "LDAP"
slug = models.SlugField(max_length=80, unique=True)
name = models.CharField(max_length=120)
provider_type = models.CharField(
max_length=20,
choices=ProviderType.choices,
default=ProviderType.OIDC,
)
enabled = models.BooleanField(default=False)
button_label = models.CharField(max_length=120, blank=True)
issuer = models.URLField(max_length=500, blank=True)
metadata_url = models.URLField(max_length=500, blank=True)
client_id = models.CharField(max_length=255, blank=True)
credential_encrypted = models.TextField(blank=True)
scopes = models.JSONField(default=list, blank=True)
claim_mappings = models.JSONField(default=dict, blank=True)
configuration = models.JSONField(default=dict, blank=True)
auto_create_users = models.BooleanField(default=True)
sync_profile = models.BooleanField(default=True)
sync_groups = models.BooleanField(default=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_identity_providers",
)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="updated_identity_providers",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name", "id"]
indexes = [
models.Index(fields=["provider_type", "enabled"], name="acct_idp_type_enabled_idx"),
]
def __str__(self) -> str:
return self.name
class ExternalIdentity(models.Model):
provider = models.ForeignKey(
EnterpriseIdentityProvider,
on_delete=models.CASCADE,
related_name="external_identities",
)
subject = models.CharField(max_length=255)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="external_identities",
)
claims_snapshot = models.JSONField(default=dict, blank=True)
last_login_at = models.DateTimeField(null=True, blank=True)
last_synced_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["provider_id", "subject"]
constraints = [
models.UniqueConstraint(
fields=["provider", "subject"],
name="acct_identity_provider_subject_uniq",
),
models.UniqueConstraint(
fields=["provider", "user"],
name="acct_identity_provider_user_uniq",
),
]
indexes = [
models.Index(fields=["user", "provider"], name="acct_identity_user_idp_idx"),
]
class EnterpriseGroupMapping(models.Model):
provider = models.ForeignKey(
EnterpriseIdentityProvider,
on_delete=models.CASCADE,
related_name="group_mappings",
)
external_group = models.CharField(max_length=255)
user_group = models.ForeignKey(
UserGroup,
on_delete=models.CASCADE,
related_name="enterprise_identity_mappings",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["provider_id", "external_group"]
constraints = [
models.UniqueConstraint(
fields=["provider", "external_group"],
name="acct_idp_external_group_uniq",
),
]
class EnterpriseDirectorySyncRun(models.Model):
class Status(models.TextChoices):
RUNNING = "running", "运行中"
COMPLETED = "completed", "已完成"
PARTIAL = "partial", "部分完成"
FAILED = "failed", "失败"
provider = models.ForeignKey(
EnterpriseIdentityProvider,
on_delete=models.CASCADE,
related_name="directory_sync_runs",
)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.RUNNING)
total_count = models.PositiveIntegerField(default=0)
created_count = models.PositiveIntegerField(default=0)
updated_count = models.PositiveIntegerField(default=0)
failed_count = models.PositiveIntegerField(default=0)
errors = models.JSONField(default=list, blank=True)
initiated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="enterprise_directory_sync_runs",
)
started_at = models.DateTimeField(auto_now_add=True)
finished_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-started_at", "-id"]
indexes = [
models.Index(fields=["provider", "started_at"], name="acct_dirsync_idp_started_idx"),
]

View File

@@ -0,0 +1,72 @@
"""OIDC 客户端构建和安全重定向辅助函数。"""
from urllib.parse import quote
from django.conf import settings
from apps.accounts.models import EnterpriseIdentityProvider
from apps.modelhub.crypto import decrypt_secret
class OIDCConfigurationError(ValueError):
pass
def provider_metadata_url(provider: EnterpriseIdentityProvider) -> str:
if provider.metadata_url:
return provider.metadata_url
if provider.issuer:
return f"{provider.issuer.rstrip('/')}/.well-known/openid-configuration"
raise OIDCConfigurationError("OIDC 提供方缺少发现文档地址或 issuer")
def build_oidc_client(provider: EnterpriseIdentityProvider):
"""构建依赖发现文档和加密凭据的 OIDC 客户端。"""
try:
from authlib.integrations.django_client import OAuth
except ImportError as error:
raise OIDCConfigurationError("服务端未安装 Authlib") from error
if not provider.client_id:
raise OIDCConfigurationError("OIDC 提供方缺少 client_id")
if not provider.credential_encrypted:
raise OIDCConfigurationError("OIDC 提供方缺少 client secret")
scopes = provider.scopes or ["openid", "profile", "email"]
if "openid" not in scopes:
raise OIDCConfigurationError("OIDC scopes 必须包含 openid")
oauth = OAuth()
return oauth.register(
name=f"enterprise_oidc_{provider.pk}",
client_id=provider.client_id,
client_secret=decrypt_secret(provider.credential_encrypted),
server_metadata_url=provider_metadata_url(provider),
client_kwargs={
"scope": " ".join(scopes),
"token_endpoint_auth_method": provider.configuration.get(
"token_endpoint_auth_method",
"client_secret_basic",
),
},
)
def callback_uri(request, provider: EnterpriseIdentityProvider) -> str:
path = f"/api/identity/auth/oidc/{provider.slug}/callback"
if settings.OIDC_CALLBACK_BASE_URL:
return f"{settings.OIDC_CALLBACK_BASE_URL}{path}"
return request.build_absolute_uri(path)
def normalize_return_to(value: str | None) -> str:
"""只允许站内绝对路径,阻断协议相对地址和外部开放重定向。"""
if not value or not value.startswith("/") or value.startswith("//") or len(value) > 500:
return "/"
return value
def frontend_redirect(return_to: str = "/", error: str = "") -> str:
if error:
return f"{settings.FRONTEND_APP_URL}/#/login?auth_error={quote(error[:240])}"
return f"{settings.FRONTEND_APP_URL}/#{normalize_return_to(return_to)}"

View File

@@ -0,0 +1,238 @@
from unittest.mock import patch
import pytest
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.http import HttpResponseRedirect
from apps.accounts.models import (
EnterpriseGroupMapping,
EnterpriseIdentityProvider,
ExternalIdentity,
UserGroup,
)
from apps.audit.models import OperationAuditLog
from apps.modelhub.crypto import encrypt_secret
pytestmark = pytest.mark.django_db
def login_admin(client):
call_command("bootstrap_governance")
admin = get_user_model().objects.get(username="admin")
client.force_login(admin)
return admin
def oidc_provider(**overrides):
values = {
"slug": "corporate",
"name": "企业统一身份",
"provider_type": "oidc",
"enabled": True,
"issuer": "https://idp.example.com",
"client_id": "ticket-platform",
"credential_encrypted": encrypt_secret("client-secret"),
"scopes": ["openid", "profile", "email"],
"claim_mappings": {},
"configuration": {},
}
values.update(overrides)
return EnterpriseIdentityProvider.objects.create(**values)
def test_provider_management_encrypts_credentials_and_exposes_safe_public_list(client):
login_admin(client)
response = client.post(
"/api/identity/providers",
data={
"slug": "corp-oidc",
"name": "公司 SSO",
"provider_type": "oidc",
"enabled": True,
"issuer": "https://idp.example.com",
"client_id": "ticket-platform",
"credential": "top-secret-value",
"scopes": ["openid", "profile", "email"],
},
content_type="application/json",
)
assert response.status_code == 200
payload = response.json()
assert payload["credential_set"] is True
assert "top-secret-value" not in str(payload)
provider = EnterpriseIdentityProvider.objects.get(slug="corp-oidc")
assert provider.credential_encrypted != "top-secret-value"
client.logout()
public_response = client.get("/api/identity/auth/providers")
assert public_response.status_code == 200
assert public_response.json() == [
{
"slug": "corp-oidc",
"name": "公司 SSO",
"button_label": "使用 公司 SSO 登录",
"provider_type": "oidc",
}
]
def test_provider_rejects_plaintext_secrets_inside_configuration(client):
login_admin(client)
response = client.post(
"/api/identity/providers",
data={
"slug": "unsafe",
"name": "不安全配置",
"provider_type": "ldap",
"configuration": {
"server_uri": "ldaps://directory.example.com",
"bind_password": "must-not-be-plain",
},
},
content_type="application/json",
)
assert response.status_code == 400
assert not EnterpriseIdentityProvider.objects.filter(slug="unsafe").exists()
def test_directory_sync_is_idempotent_and_maps_enterprise_groups(client):
login_admin(client)
provider = oidc_provider(
enabled=False,
claim_mappings={
"subject": "employee.id",
"username": "employee.account",
"email": "employee.mail",
"display_name": "employee.name",
"groups": "organization.memberships",
},
)
support = UserGroup.objects.create(name="一线支持", code="support_l1")
EnterpriseGroupMapping.objects.create(
provider=provider,
external_group="IT-Support",
user_group=support,
)
url = f"/api/identity/providers/{provider.id}/directory-sync"
record = {
"subject": "employee-1001",
"username": "zhangsan",
"email": "zhangsan@example.com",
"display_name": "张三",
"groups": ["IT-Support"],
}
first = client.post(
url,
data={"records": [record]},
content_type="application/json",
)
second = client.post(
url,
data={"records": [{**record, "display_name": "张三(支持)"}]},
content_type="application/json",
)
assert first.status_code == 200
assert first.json()["created_count"] == 1
assert second.status_code == 200
assert second.json()["updated_count"] == 1
assert ExternalIdentity.objects.filter(provider=provider).count() == 1
identity = ExternalIdentity.objects.select_related("user").get(provider=provider)
assert identity.user.first_name == "张三(支持)"
assert identity.user.business_groups.filter(id=support.id).exists()
assert not identity.user.has_usable_password()
def test_oidc_callback_logs_in_user_and_reuses_external_identity(client):
provider = oidc_provider()
support = UserGroup.objects.create(name="企业支持组", code="enterprise_support")
EnterpriseGroupMapping.objects.create(
provider=provider,
external_group="support",
user_group=support,
)
class FakeOIDCClient:
def authorize_access_token(self, request):
return {
"userinfo": {
"sub": "stable-subject",
"preferred_username": "corp-user",
"email": "corp-user@example.com",
"name": "企业用户",
"groups": ["support"],
}
}
session = client.session
session[f"identity_return_to:{provider.slug}"] = "/tickets"
session.save()
callback = f"/api/identity/auth/oidc/{provider.slug}/callback"
with patch(
"apps.accounts.identity_api.build_oidc_client",
return_value=FakeOIDCClient(),
):
first = client.get(callback)
client.logout()
second = client.get(callback)
assert first.status_code == 302
assert first.url.endswith("/#/tickets")
assert second.status_code == 302
assert ExternalIdentity.objects.filter(provider=provider).count() == 1
identity = ExternalIdentity.objects.select_related("user").get(provider=provider)
assert identity.user.business_groups.filter(id=support.id).exists()
assert OperationAuditLog.objects.filter(
action="oidc_login",
target_id=str(identity.id),
).count() == 2
def test_oidc_start_uses_server_side_client_and_callback_failure_is_safe(client):
provider = oidc_provider()
class FakeOIDCClient:
def authorize_redirect(self, request, redirect_uri):
assert redirect_uri.endswith(f"/api/identity/auth/oidc/{provider.slug}/callback")
return HttpResponseRedirect("https://idp.example.com/authorize?state=managed")
def authorize_access_token(self, request):
return {"access_token": "must-never-be-audited"}
with patch(
"apps.accounts.identity_api.build_oidc_client",
return_value=FakeOIDCClient(),
):
start = client.get(
f"/api/identity/auth/oidc/{provider.slug}/start",
{"return_to": "//evil.example.com"},
)
callback = client.get(f"/api/identity/auth/oidc/{provider.slug}/callback")
assert start.status_code == 302
assert start.url.startswith("https://idp.example.com/authorize")
assert callback.status_code == 302
assert "auth_error=" in callback.url
audit = OperationAuditLog.objects.get(action="oidc_callback_failed")
assert "must-never-be-audited" not in str(audit.request_snapshot)
def test_identity_governance_requires_permission(client):
provider = oidc_provider(enabled=False)
assert client.get("/api/identity/providers").status_code == 401
assert (
client.post(
f"/api/identity/providers/{provider.id}/directory-sync",
data={"records": []},
content_type="application/json",
).status_code
== 401
)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,242 @@
from django.db import transaction
from ninja import Router
from ninja.errors import HttpError
from apps.accounts.api import require_permission
from apps.channels.models import ChannelEndpoint, ChannelMessage
from apps.channels.schemas import (
ChannelEndpointIn,
ChannelEndpointOut,
ChannelEndpointUpdateIn,
ChannelInboundIn,
ChannelMessageOut,
ChannelOutboundIn,
ChannelReceiptIn,
)
from apps.channels.services import (
ChannelError,
ingest_inbound_message,
queue_outbound_message,
record_channel_receipt,
verify_webhook_signature,
)
from apps.modelhub.crypto import encrypt_secret
router = Router(tags=["channels"])
def serialize_endpoint(endpoint: ChannelEndpoint) -> dict:
return {
"id": endpoint.id,
"public_id": endpoint.public_id,
"name": endpoint.name,
"adapter_type": endpoint.adapter_type,
"outbound_url": endpoint.outbound_url,
"webhook_secret_set": bool(endpoint.webhook_secret_encrypted),
"outbound_token_set": bool(endpoint.outbound_token_encrypted),
"enabled": endpoint.enabled,
"created_at": endpoint.created_at,
"updated_at": endpoint.updated_at,
}
def serialize_message(message: ChannelMessage) -> dict:
return {
"public_id": message.public_id,
"endpoint_id": message.endpoint_id,
"endpoint_name": message.endpoint.name,
"conversation_public_id": (
message.conversation.public_id if message.conversation else None
),
"external_conversation_id": (
message.conversation.external_conversation_id
if message.conversation
else ""
),
"ticket_id": message.ticket_id,
"customer_reply_task_id": message.customer_reply_task_id,
"reply_revision": message.reply_revision,
"direction": message.direction,
"external_message_id": message.external_message_id,
"idempotency_key": message.idempotency_key,
"sender": message.sender,
"message_type": message.message_type,
"text": message.text,
"payload": message.payload,
"status": message.status,
"error_message": message.error_message,
"occurred_at": message.occurred_at,
"sent_at": message.sent_at,
"delivered_at": message.delivered_at,
"created_at": message.created_at,
"updated_at": message.updated_at,
}
def get_endpoint(public_id: str) -> ChannelEndpoint:
endpoint = ChannelEndpoint.objects.filter(public_id=public_id).first()
if endpoint is None:
raise HttpError(404, "渠道不存在")
return endpoint
@router.get("/endpoints", response=list[ChannelEndpointOut])
def list_channel_endpoints(request):
require_permission(request, "channel.view")
return [serialize_endpoint(item) for item in ChannelEndpoint.objects.all()]
@router.post("/endpoints", response=ChannelEndpointOut)
def create_channel_endpoint(request, payload: ChannelEndpointIn):
require_permission(request, "channel.manage")
if payload.adapter_type != "generic_webhook":
raise HttpError(400, "首期仅支持 generic_webhook 适配器")
if not payload.webhook_secret.strip():
raise HttpError(400, "Webhook 密钥不能为空")
endpoint = ChannelEndpoint.objects.create(
name=payload.name,
adapter_type=payload.adapter_type,
outbound_url=payload.outbound_url,
webhook_secret_encrypted=encrypt_secret(payload.webhook_secret),
outbound_token_encrypted=(
encrypt_secret(payload.outbound_token) if payload.outbound_token else ""
),
enabled=payload.enabled,
created_by=request.user,
)
return serialize_endpoint(endpoint)
@router.patch("/endpoints/{endpoint_id}", response=ChannelEndpointOut)
def update_channel_endpoint(
request,
endpoint_id: int,
payload: ChannelEndpointUpdateIn,
):
require_permission(request, "channel.manage")
endpoint = ChannelEndpoint.objects.filter(id=endpoint_id).first()
if endpoint is None:
raise HttpError(404, "渠道不存在")
changes = payload.dict(exclude_unset=True)
for field in ("name", "outbound_url", "enabled"):
if field in changes:
setattr(endpoint, field, changes[field])
if changes.get("webhook_secret"):
endpoint.webhook_secret_encrypted = encrypt_secret(changes["webhook_secret"])
if changes.get("outbound_token"):
endpoint.outbound_token_encrypted = encrypt_secret(changes["outbound_token"])
endpoint.save()
return serialize_endpoint(endpoint)
@router.get("/messages", response=list[ChannelMessageOut])
def list_channel_messages(
request,
endpoint_id: int | None = None,
direction: str = "",
status: str = "",
):
require_permission(request, "channel.view")
queryset = ChannelMessage.objects.select_related("endpoint", "conversation")
if endpoint_id:
queryset = queryset.filter(endpoint_id=endpoint_id)
if direction:
queryset = queryset.filter(direction=direction)
if status:
queryset = queryset.filter(status=status)
return [serialize_message(item) for item in queryset[:100]]
@router.post(
"/endpoints/{endpoint_id}/messages",
response=ChannelMessageOut,
)
def send_channel_message(request, endpoint_id: int, payload: ChannelOutboundIn):
require_permission(request, "channel.send")
require_permission(request, "ticket.reply")
endpoint = ChannelEndpoint.objects.filter(id=endpoint_id).first()
if endpoint is None:
raise HttpError(404, "渠道不存在")
try:
message = queue_outbound_message(
endpoint,
payload.dict(),
actor=request.user,
)
except ChannelError as error:
raise HttpError(400, str(error)) from error
message = ChannelMessage.objects.select_related("endpoint", "conversation").get(
id=message.id
)
return serialize_message(message)
@router.post("/messages/{public_id}/retry", response=ChannelMessageOut)
def retry_channel_message(request, public_id: str):
require_permission(request, "channel.send")
require_permission(request, "ticket.reply")
message = (
ChannelMessage.objects.select_related("endpoint", "conversation")
.filter(public_id=public_id, direction="outbound")
.first()
)
if message is None:
raise HttpError(404, "出站消息不存在")
if message.status != "failed":
raise HttpError(409, "仅失败消息可以重试")
message.status = "queued"
message.error_message = ""
message.save(update_fields=["status", "error_message", "updated_at"])
def enqueue_delivery():
from apps.channels.tasks import deliver_channel_message_task
deliver_channel_message_task.delay(message.id)
transaction.on_commit(enqueue_delivery)
return serialize_message(message)
@router.post(
"/webhooks/{public_id}/messages",
response=ChannelMessageOut,
auth=None,
)
def receive_channel_message(request, public_id: str, payload: ChannelInboundIn):
endpoint = get_endpoint(public_id)
try:
verify_webhook_signature(
endpoint,
request.body,
request.headers.get("X-Channel-Signature", ""),
)
except ChannelError as error:
raise HttpError(401, str(error)) from error
try:
message = ingest_inbound_message(endpoint, payload.dict())
except ChannelError as error:
raise HttpError(400, str(error)) from error
return serialize_message(
ChannelMessage.objects.select_related("endpoint", "conversation").get(
id=message.id
)
)
@router.post(
"/webhooks/{public_id}/receipts",
response={200: dict},
auth=None,
)
def receive_channel_receipt(request, public_id: str, payload: ChannelReceiptIn):
endpoint = get_endpoint(public_id)
try:
verify_webhook_signature(
endpoint,
request.body,
request.headers.get("X-Channel-Signature", ""),
)
receipt = record_channel_receipt(endpoint, payload.dict())
except ChannelError as error:
raise HttpError(400, str(error)) from error
return 200, {"status": "accepted", "receipt_id": receipt.id}

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ChannelsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.channels"

View File

@@ -0,0 +1,112 @@
# Generated by Django 5.2.8 on 2026-07-27
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("tickets", "0013_ticketsupportanalysis_applied_solution_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ChannelEndpoint",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
("name", models.CharField(max_length=120, unique=True)),
("adapter_type", models.CharField(default="generic_webhook", max_length=40)),
("outbound_url", models.URLField(blank=True, max_length=500)),
("webhook_secret_encrypted", models.TextField(blank=True)),
("outbound_token_encrypted", models.TextField(blank=True)),
("enabled", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("created_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_channel_endpoints", to=settings.AUTH_USER_MODEL)),
],
options={"ordering": ["name"]},
),
migrations.CreateModel(
name="ChannelConversation",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
("external_conversation_id", models.CharField(max_length=200)),
("title", models.CharField(blank=True, max_length=200)),
("participants", models.JSONField(blank=True, default=list)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("endpoint", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="conversations", to="channels.channelendpoint")),
("ticket", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="channel_conversations", to="tickets.ticket")),
],
options={"ordering": ["-updated_at"]},
),
migrations.CreateModel(
name="ChannelMessage",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
("direction", models.CharField(max_length=16)),
("external_message_id", models.CharField(blank=True, max_length=200)),
("idempotency_key", models.CharField(blank=True, max_length=200)),
("sender", models.CharField(blank=True, max_length=160)),
("message_type", models.CharField(default="text", max_length=24)),
("text", models.TextField(blank=True)),
("payload", models.JSONField(blank=True, default=dict)),
("status", models.CharField(max_length=24)),
("error_message", models.TextField(blank=True)),
("occurred_at", models.DateTimeField(blank=True, null=True)),
("sent_at", models.DateTimeField(blank=True, null=True)),
("delivered_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("conversation", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="messages", to="channels.channelconversation")),
("created_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_channel_messages", to=settings.AUTH_USER_MODEL)),
("endpoint", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="messages", to="channels.channelendpoint")),
("ticket", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="channel_messages", to="tickets.ticket")),
],
options={"ordering": ["-created_at"]},
),
migrations.CreateModel(
name="ChannelReceipt",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("receipt_type", models.CharField(max_length=24)),
("external_receipt_id", models.CharField(max_length=200)),
("payload", models.JSONField(blank=True, default=dict)),
("received_at", models.DateTimeField(auto_now_add=True)),
("message", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="receipts", to="channels.channelmessage")),
],
options={"ordering": ["received_at"]},
),
migrations.AddConstraint(
model_name="channelconversation",
constraint=models.UniqueConstraint(fields=("endpoint", "external_conversation_id"), name="unique_channel_external_conversation"),
),
migrations.AddIndex(
model_name="channelmessage",
index=models.Index(fields=["endpoint", "direction", "created_at"], name="channels_ch_endpoin_30d460_idx"),
),
migrations.AddIndex(
model_name="channelmessage",
index=models.Index(fields=["status", "created_at"], name="channels_ch_status_eeefec_idx"),
),
migrations.AddConstraint(
model_name="channelmessage",
constraint=models.UniqueConstraint(condition=models.Q(("external_message_id__gt", "")), fields=("endpoint", "direction", "external_message_id"), name="unique_channel_external_message"),
),
migrations.AddConstraint(
model_name="channelmessage",
constraint=models.UniqueConstraint(condition=models.Q(("idempotency_key__gt", "")), fields=("endpoint", "direction", "idempotency_key"), name="unique_channel_message_idempotency"),
),
migrations.AddConstraint(
model_name="channelreceipt",
constraint=models.UniqueConstraint(fields=("message", "receipt_type", "external_receipt_id"), name="unique_channel_message_receipt"),
),
]

View File

@@ -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",
),
),
]

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,157 @@
import uuid
from django.conf import settings
from django.db import models
from django.db.models import Q
class ChannelEndpoint(models.Model):
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
name = models.CharField(max_length=120, unique=True)
adapter_type = models.CharField(max_length=40, default="generic_webhook")
outbound_url = models.URLField(max_length=500, blank=True)
webhook_secret_encrypted = models.TextField(blank=True)
outbound_token_encrypted = models.TextField(blank=True)
enabled = models.BooleanField(default=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_channel_endpoints",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
class ChannelConversation(models.Model):
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
endpoint = models.ForeignKey(
ChannelEndpoint,
on_delete=models.PROTECT,
related_name="conversations",
)
external_conversation_id = models.CharField(max_length=200)
title = models.CharField(max_length=200, blank=True)
participants = models.JSONField(default=list, blank=True)
ticket = models.ForeignKey(
"tickets.Ticket",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="channel_conversations",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-updated_at"]
constraints = [
models.UniqueConstraint(
fields=["endpoint", "external_conversation_id"],
name="unique_channel_external_conversation",
)
]
class ChannelMessage(models.Model):
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
endpoint = models.ForeignKey(
ChannelEndpoint,
on_delete=models.PROTECT,
related_name="messages",
)
conversation = models.ForeignKey(
ChannelConversation,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="messages",
)
ticket = models.ForeignKey(
"tickets.Ticket",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="channel_messages",
)
customer_reply_task = models.ForeignKey(
"tickets.CustomerReplyTask",
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="channel_messages",
)
reply_revision = models.PositiveIntegerField(null=True, blank=True)
direction = models.CharField(max_length=16)
external_message_id = models.CharField(max_length=200, blank=True)
idempotency_key = models.CharField(max_length=200, blank=True)
sender = models.CharField(max_length=160, blank=True)
message_type = models.CharField(max_length=24, default="text")
text = models.TextField(blank=True)
payload = models.JSONField(default=dict, blank=True)
status = models.CharField(max_length=24)
error_message = models.TextField(blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_channel_messages",
)
occurred_at = models.DateTimeField(null=True, blank=True)
sent_at = models.DateTimeField(null=True, blank=True)
delivered_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["endpoint", "direction", "external_message_id"],
condition=Q(external_message_id__gt=""),
name="unique_channel_external_message",
),
models.UniqueConstraint(
fields=["endpoint", "direction", "idempotency_key"],
condition=Q(idempotency_key__gt=""),
name="unique_channel_message_idempotency",
),
models.UniqueConstraint(
fields=["customer_reply_task", "reply_revision"],
condition=Q(
direction="outbound",
customer_reply_task__isnull=False,
),
name="unique_channel_reply_revision",
),
]
indexes = [
models.Index(fields=["endpoint", "direction", "created_at"]),
models.Index(fields=["status", "created_at"]),
]
class ChannelReceipt(models.Model):
message = models.ForeignKey(
ChannelMessage,
on_delete=models.CASCADE,
related_name="receipts",
)
receipt_type = models.CharField(max_length=24)
external_receipt_id = models.CharField(max_length=200)
payload = models.JSONField(default=dict, blank=True)
received_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["received_at"]
constraints = [
models.UniqueConstraint(
fields=["message", "receipt_type", "external_receipt_id"],
name="unique_channel_message_receipt",
)
]

View File

@@ -0,0 +1,86 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from ninja import Schema
class ChannelEndpointIn(Schema):
name: str
adapter_type: str = "generic_webhook"
outbound_url: str = ""
webhook_secret: str
outbound_token: str = ""
enabled: bool = True
class ChannelEndpointOut(Schema):
id: int
public_id: UUID
name: str
adapter_type: str
outbound_url: str
webhook_secret_set: bool
outbound_token_set: bool
enabled: bool
created_at: datetime
updated_at: datetime
class ChannelEndpointUpdateIn(Schema):
name: str | None = None
outbound_url: str | None = None
webhook_secret: str | None = None
outbound_token: str | None = None
enabled: bool | None = None
class ChannelInboundIn(Schema):
external_message_id: str
external_conversation_id: str
text: str
sender: str = ""
title: str = ""
participants: list[str] = []
occurred_at: datetime | None = None
payload: dict[str, Any] = {}
class ChannelReceiptIn(Schema):
external_message_id: str
external_receipt_id: str
receipt_type: str
payload: dict[str, Any] = {}
class ChannelOutboundIn(Schema):
external_conversation_id: str
idempotency_key: str
customer_reply_task_id: int
expected_revision: int
payload: dict[str, Any] = {}
class ChannelMessageOut(Schema):
public_id: UUID
endpoint_id: int
endpoint_name: str
conversation_public_id: UUID | None
external_conversation_id: str
ticket_id: int | None
customer_reply_task_id: int | None
reply_revision: int | None
direction: str
external_message_id: str
idempotency_key: str
sender: str
message_type: str
text: str
payload: dict[str, Any]
status: str
error_message: str
occurred_at: datetime | None
sent_at: datetime | None
delivered_at: datetime | None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,375 @@
"""企业渠道消息的签名校验、幂等收发、回执和客户回复任务联动。"""
import hashlib
import hmac
import json
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from django.db import transaction
from django.utils import timezone
from apps.audit.models import OperationAuditLog
from apps.channels.models import (
ChannelConversation,
ChannelEndpoint,
ChannelMessage,
ChannelReceipt,
)
from apps.modelhub.crypto import decrypt_secret
from apps.tickets.models import CustomerReplyTask, TicketActorSource
from apps.tickets.replies import (
CustomerReplyError,
approve_customer_reply_for_channel,
confirm_customer_reply_sent,
)
class ChannelError(ValueError):
pass
def verify_webhook_signature(endpoint: ChannelEndpoint, body: bytes, signature: str) -> None:
"""使用常量时间比较验证原始请求体的 HMAC-SHA256 签名。"""
if not endpoint.webhook_secret_encrypted:
raise ChannelError("渠道未配置 Webhook 密钥")
secret = decrypt_secret(endpoint.webhook_secret_encrypted).encode()
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
normalized = signature.removeprefix("sha256=").strip()
if not normalized or not hmac.compare_digest(expected, normalized):
raise ChannelError("Webhook 签名无效")
@transaction.atomic
def ingest_inbound_message(endpoint: ChannelEndpoint, payload: dict) -> ChannelMessage:
"""以渠道消息 ID 幂等写入入站消息和会话,不在此处隐式创建工单。"""
endpoint = ChannelEndpoint.objects.select_for_update().get(id=endpoint.id)
if not endpoint.enabled:
raise ChannelError("渠道已停用")
external_message_id = str(payload.get("external_message_id") or "").strip()
conversation_id = str(payload.get("external_conversation_id") or "").strip()
text = str(payload.get("text") or "").strip()
if not external_message_id or not conversation_id or not text:
raise ChannelError("消息 ID、会话 ID 和文本不能为空")
existing = ChannelMessage.objects.filter(
endpoint=endpoint,
direction="inbound",
external_message_id=external_message_id,
).first()
if existing is not None:
return existing
conversation, _ = ChannelConversation.objects.get_or_create(
endpoint=endpoint,
external_conversation_id=conversation_id,
defaults={
"title": str(payload.get("title") or "")[:200],
"participants": payload.get("participants")
if isinstance(payload.get("participants"), list)
else [],
},
)
message = ChannelMessage.objects.create(
endpoint=endpoint,
conversation=conversation,
ticket=conversation.ticket,
direction="inbound",
external_message_id=external_message_id,
idempotency_key=external_message_id,
sender=str(payload.get("sender") or "")[:160],
text=text,
payload=payload.get("payload") if isinstance(payload.get("payload"), dict) else {},
status="received",
occurred_at=payload.get("occurred_at"),
)
OperationAuditLog.objects.create(
module="channels",
action="message_received",
target_type="channel_message",
target_id=str(message.public_id),
request_snapshot={
"endpoint_id": endpoint.id,
"external_message_id": external_message_id,
"conversation_id": conversation_id,
},
result="success",
)
return message
def _validate_outbound_url(value: str) -> None:
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username:
raise ChannelError("出站地址必须是无内嵌凭证的 HTTP(S) URL")
def _confirm_delivered_reply(message: ChannelMessage) -> None:
if not message.customer_reply_task_id:
return
try:
confirm_customer_reply_sent(
message.customer_reply_task,
expected_revision=message.reply_revision,
actor=message.created_by,
source=TicketActorSource.CHANNEL,
channel_message_public_id=str(message.public_id),
)
except CustomerReplyError as error:
raise ChannelError(f"渠道已送达,但回复任务回写失败:{error}") from error
def deliver_outbound_message(message_id: int) -> ChannelMessage:
"""投递已批准的回复版本,并只在明确送达后完成客户回复任务。"""
message = ChannelMessage.objects.select_related(
"endpoint",
"conversation",
"customer_reply_task",
).get(id=message_id)
endpoint = message.endpoint
if message.status == "delivered":
_confirm_delivered_reply(message)
return message
if message.status not in {"queued", "failed"}:
return message
if not endpoint.enabled or not endpoint.outbound_url:
message.status = "failed"
message.error_message = "渠道未启用或未配置出站地址"
message.save(update_fields=["status", "error_message", "updated_at"])
return message
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Idempotency-Key": message.idempotency_key,
}
if endpoint.outbound_token_encrypted:
headers["Authorization"] = (
f"Bearer {decrypt_secret(endpoint.outbound_token_encrypted)}"
)
body = json.dumps(
{
"conversation_id": message.conversation.external_conversation_id,
"text": message.text,
"message_id": str(message.public_id),
"metadata": message.payload,
},
ensure_ascii=False,
).encode()
try:
_validate_outbound_url(endpoint.outbound_url)
request = Request(endpoint.outbound_url, data=body, headers=headers, method="POST")
with urlopen(request, timeout=30) as response:
result = json.loads(response.read().decode("utf-8"))
if not isinstance(result, dict):
raise ChannelError("渠道返回结构无效")
except (
HTTPError,
URLError,
TimeoutError,
json.JSONDecodeError,
ChannelError,
) as error:
message.status = "failed"
message.error_message = str(error) or "渠道发送失败"
else:
message.status = str(result.get("status") or "sent")
if message.status not in {"sent", "delivered"}:
message.status = "sent"
message.external_message_id = str(result.get("external_message_id") or "")
message.error_message = ""
message.sent_at = timezone.now()
if message.status == "delivered":
message.delivered_at = timezone.now()
message.save()
if message.status == "delivered":
_confirm_delivered_reply(message)
OperationAuditLog.objects.create(
user=message.created_by,
module="channels",
action="message_delivery_finished",
target_type="channel_message",
target_id=str(message.public_id),
request_snapshot={
"endpoint_id": endpoint.id,
"status": message.status,
"idempotency_key": message.idempotency_key,
},
result="success" if message.status in {"sent", "delivered"} else "failed",
)
return message
@transaction.atomic
def queue_outbound_message(
endpoint: ChannelEndpoint,
payload: dict,
*,
actor,
) -> ChannelMessage:
"""冻结客户回复版本并生成唯一出站消息,提交后再投递异步任务。"""
endpoint = ChannelEndpoint.objects.select_for_update().get(id=endpoint.id)
if not endpoint.enabled:
raise ChannelError("渠道已停用")
idempotency_key = str(payload.get("idempotency_key") or "").strip()
conversation_id = str(payload.get("external_conversation_id") or "").strip()
if not idempotency_key or not conversation_id:
raise ChannelError("幂等键和会话 ID 不能为空")
try:
reply_task_id = int(payload.get("customer_reply_task_id"))
expected_revision = int(payload.get("expected_revision"))
except (TypeError, ValueError) as error:
raise ChannelError("客户回复任务和版本不能为空") from error
existing = (
ChannelMessage.objects.select_related("conversation")
.filter(
endpoint=endpoint,
direction="outbound",
idempotency_key=idempotency_key,
)
.first()
)
if existing is not None:
if (
existing.customer_reply_task_id == reply_task_id
and existing.reply_revision == expected_revision
and existing.conversation
and existing.conversation.external_conversation_id == conversation_id
):
return existing
raise ChannelError("幂等键已用于其他客户回复")
reply_task = (
CustomerReplyTask.objects.select_related("ticket", "support_analysis")
.filter(id=reply_task_id)
.first()
)
if reply_task is None:
raise ChannelError("客户回复任务不存在")
try:
reply_task = approve_customer_reply_for_channel(
reply_task,
expected_revision=expected_revision,
actor=actor,
channel_endpoint_id=endpoint.id,
external_conversation_id=conversation_id,
)
except CustomerReplyError as error:
raise ChannelError(str(error)) from error
existing_reply = (
ChannelMessage.objects.select_related("conversation")
.filter(
customer_reply_task=reply_task,
reply_revision=reply_task.revision,
direction="outbound",
)
.first()
)
if existing_reply is not None:
if (
existing_reply.endpoint_id == endpoint.id
and existing_reply.conversation
and existing_reply.conversation.external_conversation_id == conversation_id
):
return existing_reply
raise ChannelError("该客户回复版本已进入其他渠道会话")
conversation, _ = ChannelConversation.objects.get_or_create(
endpoint=endpoint,
external_conversation_id=conversation_id,
)
if conversation.ticket_id and conversation.ticket_id != reply_task.ticket_id:
raise ChannelError("渠道会话已关联其他工单")
if not conversation.ticket_id:
conversation.ticket = reply_task.ticket
conversation.save(update_fields=["ticket", "updated_at"])
metadata = payload.get("payload") if isinstance(payload.get("payload"), dict) else {}
metadata = {
**metadata,
"customer_reply_task_id": reply_task.id,
"reply_revision": reply_task.revision,
}
message = ChannelMessage.objects.create(
endpoint=endpoint,
conversation=conversation,
ticket=reply_task.ticket,
customer_reply_task=reply_task,
reply_revision=reply_task.revision,
direction="outbound",
idempotency_key=idempotency_key,
sender=actor.get_full_name() or actor.username,
text=reply_task.final_text,
payload=metadata,
status="queued",
created_by=actor,
)
OperationAuditLog.objects.create(
user=actor,
module="channels",
action="customer_reply_queued",
target_type="channel_message",
target_id=str(message.public_id),
request_snapshot={
"endpoint_id": endpoint.id,
"ticket_id": reply_task.ticket_id,
"customer_reply_task_id": reply_task.id,
"reply_revision": reply_task.revision,
"idempotency_key": idempotency_key,
},
result="success",
)
def enqueue_delivery():
from apps.channels.tasks import deliver_channel_message_task
deliver_channel_message_task.delay(message.id)
transaction.on_commit(enqueue_delivery)
return message
@transaction.atomic
def record_channel_receipt(endpoint: ChannelEndpoint, payload: dict) -> ChannelReceipt:
"""幂等记录渠道回执并推进消息状态,重复回执不重复完成业务任务。"""
endpoint = ChannelEndpoint.objects.select_for_update().get(id=endpoint.id)
receipt_type = str(payload.get("receipt_type") or "").strip()
external_message_id = str(payload.get("external_message_id") or "").strip()
external_receipt_id = str(payload.get("external_receipt_id") or "").strip()
if receipt_type not in {"sent", "delivered", "read", "failed"}:
raise ChannelError("未知回执类型")
if not external_message_id or not external_receipt_id:
raise ChannelError("消息 ID 和回执 ID 不能为空")
message = ChannelMessage.objects.select_for_update().filter(
endpoint=endpoint,
direction="outbound",
external_message_id=external_message_id,
).first()
if message is None:
raise ChannelError("回执对应的出站消息不存在")
receipt, created = ChannelReceipt.objects.get_or_create(
message=message,
receipt_type=receipt_type,
external_receipt_id=external_receipt_id,
defaults={
"payload": payload.get("payload")
if isinstance(payload.get("payload"), dict)
else {}
},
)
if not created:
return receipt
if receipt_type == "failed":
message.status = "failed"
message.error_message = str(receipt.payload.get("reason") or "渠道返回失败回执")
elif receipt_type in {"delivered", "read"}:
message.status = "delivered"
message.delivered_at = timezone.now()
message.error_message = ""
elif message.status == "queued":
message.status = "sent"
message.sent_at = timezone.now()
message.save()
if message.status == "delivered":
_confirm_delivered_reply(message)
return receipt

View File

@@ -0,0 +1,8 @@
from celery import shared_task
from apps.channels.services import deliver_outbound_message
@shared_task(name="apps.channels.tasks.deliver_channel_message")
def deliver_channel_message_task(message_id: int):
return deliver_outbound_message(message_id).status

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,286 @@
import hashlib
import hmac
import json
import pytest
from django.contrib.auth import get_user_model
from apps.audit.models import OperationAuditLog
from apps.channels.models import ChannelEndpoint, ChannelMessage, ChannelReceipt
from apps.channels.services import (
ChannelError,
queue_outbound_message,
record_channel_receipt,
)
from apps.modelhub.crypto import encrypt_secret
from apps.tickets.models import CustomerReplyTask, Ticket
from apps.workitems.models import WorkItem
class FakeResponse:
def __init__(self, payload: dict):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return False
def read(self):
return json.dumps(self.payload).encode()
def signature(secret: str, body: bytes) -> str:
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return f"sha256={digest}"
@pytest.mark.django_db
def test_signed_inbound_webhook_is_idempotent(client):
secret = "channel-inbound-secret"
endpoint = ChannelEndpoint.objects.create(
name="客服群",
webhook_secret_encrypted=encrypt_secret(secret),
)
body = json.dumps(
{
"external_message_id": "msg-in-001",
"external_conversation_id": "group-001",
"text": "生产环境登录失败,错误码 AUTH-401",
"sender": "客户甲",
"participants": ["客户甲", "技术支持"],
"payload": {"source": "group"},
},
ensure_ascii=False,
separators=(",", ":"),
).encode()
headers = {"HTTP_X_CHANNEL_SIGNATURE": signature(secret, body)}
first = client.post(
f"/api/channels/webhooks/{endpoint.public_id}/messages",
data=body,
content_type="application/json",
**headers,
)
second = client.post(
f"/api/channels/webhooks/{endpoint.public_id}/messages",
data=body,
content_type="application/json",
**headers,
)
assert first.status_code == 200
assert second.status_code == 200
assert first.json()["public_id"] == second.json()["public_id"]
assert ChannelMessage.objects.count() == 1
message = ChannelMessage.objects.get()
assert message.status == "received"
assert message.conversation.external_conversation_id == "group-001"
assert OperationAuditLog.objects.filter(
module="channels",
action="message_received",
target_id=str(message.public_id),
).count() == 1
@pytest.mark.django_db
def test_inbound_webhook_rejects_invalid_signature(client):
endpoint = ChannelEndpoint.objects.create(
name="签名渠道",
webhook_secret_encrypted=encrypt_secret("correct-secret"),
)
response = client.post(
f"/api/channels/webhooks/{endpoint.public_id}/messages",
data={
"external_message_id": "msg-invalid",
"external_conversation_id": "group-invalid",
"text": "不应被接收",
},
content_type="application/json",
HTTP_X_CHANNEL_SIGNATURE="sha256=invalid",
)
assert response.status_code == 401
assert ChannelMessage.objects.count() == 0
@pytest.mark.django_db(transaction=True)
def test_outbound_delivery_and_receipt_are_idempotent(
admin_client,
django_capture_on_commit_callbacks,
monkeypatch,
):
actor = get_user_model().objects.filter(is_superuser=True).first()
assert actor is not None
endpoint = ChannelEndpoint.objects.create(
name="出站渠道",
outbound_url="https://channel.example/messages",
webhook_secret_encrypted=encrypt_secret("receipt-secret"),
outbound_token_encrypted=encrypt_secret("outbound-token"),
)
ticket = Ticket.objects.create(
ticket_no="TK-CHANNEL-0001",
title="生产环境登录失败",
description="生产环境登录失败,错误码 AUTH-401",
status="pending",
)
reply_task = CustomerReplyTask.objects.create(
ticket=ticket,
status="draft",
draft_text="已收到问题,正在处理。",
final_text="已收到问题,正在处理。",
created_by=actor,
)
requests = []
def fake_urlopen(request, timeout):
requests.append((request, timeout))
return FakeResponse(
{
"status": "sent",
"external_message_id": "external-out-001",
}
)
monkeypatch.setattr("apps.channels.services.urlopen", fake_urlopen)
payload = {
"external_conversation_id": "group-002",
"idempotency_key": "reply-001",
"customer_reply_task_id": reply_task.id,
"expected_revision": reply_task.revision,
"payload": {"template": "ack"},
}
with django_capture_on_commit_callbacks(execute=True):
first = queue_outbound_message(endpoint, payload, actor=actor)
with django_capture_on_commit_callbacks(execute=True):
second = queue_outbound_message(endpoint, payload, actor=actor)
first.refresh_from_db()
reply_task.refresh_from_db()
ticket.refresh_from_db()
assert first.id == second.id
assert first.status == "sent"
assert first.external_message_id == "external-out-001"
assert first.customer_reply_task == reply_task
assert first.reply_revision == 1
assert first.text == reply_task.final_text
assert reply_task.status == "approved"
assert reply_task.approved_by == actor
assert reply_task.approved_at is not None
assert ticket.replied_to_customer is False
assert ChannelMessage.objects.filter(direction="outbound").count() == 1
assert len(requests) == 1
request, timeout = requests[0]
assert timeout == 30
assert request.get_header("Idempotency-key") == "reply-001"
assert request.get_header("Authorization") == "Bearer outbound-token"
receipt_payload = {
"external_message_id": "external-out-001",
"external_receipt_id": "receipt-001",
"receipt_type": "delivered",
"payload": {"provider_status": "delivered"},
}
first_receipt = record_channel_receipt(endpoint, receipt_payload)
second_receipt = record_channel_receipt(endpoint, receipt_payload)
first.refresh_from_db()
reply_task.refresh_from_db()
ticket.refresh_from_db()
assert first_receipt.id == second_receipt.id
assert first.status == "delivered"
assert first.delivered_at is not None
assert reply_task.status == "sent"
assert reply_task.sent_at is not None
assert ticket.replied_to_customer is True
assert WorkItem.objects.filter(
ticket=ticket,
task_type="ticket_customer_reply",
status="completed",
).count() == 1
assert ChannelReceipt.objects.count() == 1
@pytest.mark.django_db
def test_customer_reply_version_cannot_be_queued_to_another_conversation(admin_client):
actor = get_user_model().objects.get(is_superuser=True)
endpoint = ChannelEndpoint.objects.create(
name="唯一回复渠道",
outbound_url="https://channel.example/messages",
)
ticket = Ticket.objects.create(
ticket_no="TK-CHANNEL-0002",
title="接口超时",
description="生产接口持续超时",
status="pending",
)
reply_task = CustomerReplyTask.objects.create(
ticket=ticket,
draft_text="已定位到上游依赖超时。",
final_text="已定位到上游依赖超时。",
created_by=actor,
)
first_payload = {
"external_conversation_id": "group-a",
"idempotency_key": "reply-version-a",
"customer_reply_task_id": reply_task.id,
"expected_revision": 1,
}
queue_outbound_message(endpoint, first_payload, actor=actor)
with pytest.raises(ChannelError, match="已进入其他渠道会话"):
queue_outbound_message(
endpoint,
{
**first_payload,
"external_conversation_id": "group-b",
"idempotency_key": "reply-version-b",
},
actor=actor,
)
@pytest.mark.django_db
def test_outbound_api_rejects_arbitrary_text(admin_client):
endpoint = ChannelEndpoint.objects.create(
name="受控发送渠道",
outbound_url="https://channel.example/messages",
)
response = admin_client.post(
f"/api/channels/endpoints/{endpoint.id}/messages",
{
"external_conversation_id": "group-uncontrolled",
"idempotency_key": "uncontrolled-001",
"ticket_id": 1,
"text": "绕过客户回复任务直接发送",
},
content_type="application/json",
)
assert response.status_code == 422
assert ChannelMessage.objects.count() == 0
@pytest.mark.django_db
def test_channel_management_api_hides_credentials(admin_client):
response = admin_client.post(
"/api/channels/endpoints",
{
"name": "治理渠道",
"outbound_url": "https://channel.example/send",
"webhook_secret": "webhook-secret",
"outbound_token": "outbound-token",
},
content_type="application/json",
)
assert response.status_code == 200
payload = response.json()
assert payload["webhook_secret_set"] is True
assert payload["outbound_token_set"] is True
assert "webhook_secret" not in payload
assert "outbound_token" not in payload

View File

@@ -1,7 +1,10 @@
import os import os
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parents[3] BASE_DIR = Path(__file__).resolve().parents[3]
load_dotenv(BASE_DIR / ".env", override=False)
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me") SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me")
DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true" DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true"
@@ -13,17 +16,21 @@ INSTALLED_APPS = [
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.postgres",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"corsheaders", "corsheaders",
"apps.accounts", "apps.accounts",
"apps.audit", "apps.audit",
"apps.system", "apps.system",
"apps.tickets", "apps.tickets",
"apps.workitems",
"apps.solutions", "apps.solutions",
"apps.knowledge", "apps.knowledge",
"apps.modelhub", "apps.modelhub",
"apps.skills", "apps.skills",
"apps.agents", "apps.agents",
"apps.tools",
"apps.channels",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -102,6 +109,8 @@ CSRF_TRUSTED_ORIGINS = os.getenv(
"CSRF_TRUSTED_ORIGINS", "CSRF_TRUSTED_ORIGINS",
"http://localhost:5173,http://127.0.0.1:5173", "http://localhost:5173,http://127.0.0.1:5173",
).split(",") ).split(",")
FRONTEND_APP_URL = os.getenv("FRONTEND_APP_URL", "http://localhost:5173").rstrip("/")
OIDC_CALLBACK_BASE_URL = os.getenv("OIDC_CALLBACK_BASE_URL", "").rstrip("/")
DJANGO_SUPERUSER_USERNAME = os.getenv("DJANGO_SUPERUSER_USERNAME", "admin") DJANGO_SUPERUSER_USERNAME = os.getenv("DJANGO_SUPERUSER_USERNAME", "admin")
DJANGO_SUPERUSER_PASSWORD = os.getenv("DJANGO_SUPERUSER_PASSWORD", "admin12345") DJANGO_SUPERUSER_PASSWORD = os.getenv("DJANGO_SUPERUSER_PASSWORD", "admin12345")
@@ -111,8 +120,66 @@ CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1") CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
AI_CREDENTIAL_ENCRYPTION_KEY = os.getenv("AI_CREDENTIAL_ENCRYPTION_KEY", "") AI_CREDENTIAL_ENCRYPTION_KEY = os.getenv("AI_CREDENTIAL_ENCRYPTION_KEY", "")
INTERNAL_MCP_TOKEN = os.getenv("INTERNAL_MCP_TOKEN", "")
METRICS_TOKEN = os.getenv("METRICS_TOKEN", "")
AI_RUN_RETENTION_DAYS = int(os.getenv("AI_RUN_RETENTION_DAYS", "90")) AI_RUN_RETENTION_DAYS = int(os.getenv("AI_RUN_RETENTION_DAYS", "90"))
AI_SLOW_RUN_THRESHOLD_SECONDS = int(os.getenv("AI_SLOW_RUN_THRESHOLD_SECONDS", "20"))
AI_AGENT_MODEL_TIMEOUT_SECONDS = int(os.getenv("AI_AGENT_MODEL_TIMEOUT_SECONDS", "20"))
KNOWLEDGE_EMBEDDING_DEPLOYMENT_ID = int(
os.getenv("KNOWLEDGE_EMBEDDING_DEPLOYMENT_ID", "0")
)
KNOWLEDGE_EMBEDDING_DIMENSIONS = int(os.getenv("KNOWLEDGE_EMBEDDING_DIMENSIONS", "1536"))
KNOWLEDGE_EMBEDDING_ALLOW_HASH_FALLBACK = (
os.getenv("KNOWLEDGE_EMBEDDING_ALLOW_HASH_FALLBACK", "false").lower() == "true"
)
KNOWLEDGE_RETRIEVAL_TEXT_WEIGHT = float(
os.getenv("KNOWLEDGE_RETRIEVAL_TEXT_WEIGHT", "0.45")
)
KNOWLEDGE_RETRIEVAL_VECTOR_WEIGHT = float(
os.getenv("KNOWLEDGE_RETRIEVAL_VECTOR_WEIGHT", "0.35")
)
KNOWLEDGE_RETRIEVAL_SCOPE_WEIGHT = float(
os.getenv("KNOWLEDGE_RETRIEVAL_SCOPE_WEIGHT", "0.20")
)
KNOWLEDGE_RERANK_DEPLOYMENT_ID = int(os.getenv("KNOWLEDGE_RERANK_DEPLOYMENT_ID", "0"))
KNOWLEDGE_RERANK_CANDIDATE_LIMIT = int(
os.getenv("KNOWLEDGE_RERANK_CANDIDATE_LIMIT", "30")
)
KNOWLEDGE_RERANK_MODEL_WEIGHT = float(os.getenv("KNOWLEDGE_RERANK_MODEL_WEIGHT", "0.60"))
TICKET_AUTO_RESOLVE_ENABLED = (
os.getenv("TICKET_AUTO_RESOLVE_ENABLED", "false").lower() == "true"
)
TICKET_AUTO_RESOLVE_MIN_SCORE = float(
os.getenv("TICKET_AUTO_RESOLVE_MIN_SCORE", "0.85")
)
WORK_ITEM_DEFAULT_SLA_HOURS = int(os.getenv("WORK_ITEM_DEFAULT_SLA_HOURS", "24"))
WORK_ITEM_INTAKE_FAILURE_SLA_HOURS = int(
os.getenv("WORK_ITEM_INTAKE_FAILURE_SLA_HOURS", "4")
)
WORK_ITEM_SUPPORT_FAILURE_SLA_HOURS = int(
os.getenv("WORK_ITEM_SUPPORT_FAILURE_SLA_HOURS", "4")
)
WORK_ITEM_CUSTOMER_REPLY_SLA_HOURS = int(
os.getenv("WORK_ITEM_CUSTOMER_REPLY_SLA_HOURS", "8")
)
WORK_ITEM_TICKET_CLOSE_SLA_HOURS = int(
os.getenv("WORK_ITEM_TICKET_CLOSE_SLA_HOURS", "24")
)
TICKET_DRAFT_RETENTION_HOURS = int(os.getenv("TICKET_DRAFT_RETENTION_HOURS", "24")) TICKET_DRAFT_RETENTION_HOURS = int(os.getenv("TICKET_DRAFT_RETENTION_HOURS", "24"))
TICKET_MATERIAL_MAX_FILE_BYTES = int(
os.getenv("TICKET_MATERIAL_MAX_FILE_BYTES", str(20 * 1024 * 1024))
)
TICKET_MATERIAL_MAX_FILES = int(os.getenv("TICKET_MATERIAL_MAX_FILES", "10"))
TICKET_MATERIAL_MAX_TOTAL_BYTES = int(
os.getenv("TICKET_MATERIAL_MAX_TOTAL_BYTES", str(100 * 1024 * 1024))
)
TICKET_MATERIAL_MAX_PARSED_CHARS = int(
os.getenv("TICKET_MATERIAL_MAX_PARSED_CHARS", "200000")
)
TICKET_MATERIAL_SCANNER_BACKEND = os.getenv("TICKET_MATERIAL_SCANNER_BACKEND", "clamav")
CLAMAV_HOST = os.getenv("CLAMAV_HOST", "localhost")
CLAMAV_PORT = int(os.getenv("CLAMAV_PORT", "3310"))
CLAMAV_TIMEOUT_SECONDS = int(os.getenv("CLAMAV_TIMEOUT_SECONDS", "30"))
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000") MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", os.getenv("MINIO_ROOT_USER", "minio")) MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", os.getenv("MINIO_ROOT_USER", "minio"))

View File

@@ -7,6 +7,8 @@ DATABASES = {
} }
} }
KNOWLEDGE_EMBEDDING_ALLOW_HASH_FALLBACK = True
PASSWORD_HASHERS = [ PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher", "django.contrib.auth.hashers.MD5PasswordHasher",
] ]
@@ -14,3 +16,4 @@ PASSWORD_HASHERS = [
AI_CREDENTIAL_ENCRYPTION_KEY = "t8XuQv9e8WDoFv7RXT4-zdw1hQChGFKtr7E6ccRZAzU=" AI_CREDENTIAL_ENCRYPTION_KEY = "t8XuQv9e8WDoFv7RXT4-zdw1hQChGFKtr7E6ccRZAzU="
CELERY_TASK_ALWAYS_EAGER = True CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True CELERY_TASK_EAGER_PROPAGATES = True
TICKET_MATERIAL_SCANNER_BACKEND = "test"

View File

@@ -3,9 +3,11 @@ from django.urls import path
from ninja import NinjaAPI from ninja import NinjaAPI
from apps.accounts.api import router as accounts_router from apps.accounts.api import router as accounts_router
from apps.accounts.identity_api import router as identity_router
from apps.agents.api import router as agents_router from apps.agents.api import router as agents_router
from apps.agents.api import runs_router as agent_runs_router from apps.agents.api import runs_router as agent_runs_router
from apps.audit.api import router as audit_router from apps.audit.api import router as audit_router
from apps.channels.api import router as channels_router
from apps.knowledge.api import router as knowledge_router from apps.knowledge.api import router as knowledge_router
from apps.modelhub.api import router as modelhub_router from apps.modelhub.api import router as modelhub_router
from apps.skills.api import router as skills_router from apps.skills.api import router as skills_router
@@ -13,21 +15,29 @@ from apps.solutions.api import router as solutions_router
from apps.system.api import router as system_router from apps.system.api import router as system_router
from apps.tickets.api import router as tickets_router from apps.tickets.api import router as tickets_router
from apps.tickets.draft_api import router as ticket_drafts_router from apps.tickets.draft_api import router as ticket_drafts_router
from apps.tools.api import router as tools_router
from apps.tools.mcp_gateway import internal_mcp_view
from apps.workitems.api import router as work_items_router
api = NinjaAPI(title="AI Ticket Platform API", version="0.1.0") api = NinjaAPI(title="AI Ticket Platform API", version="0.1.0")
api.add_router("/accounts", accounts_router) api.add_router("/accounts", accounts_router)
api.add_router("/identity", identity_router)
api.add_router("/system", system_router) api.add_router("/system", system_router)
api.add_router("/audit", audit_router) api.add_router("/audit", audit_router)
api.add_router("/tickets", tickets_router) api.add_router("/tickets", tickets_router)
api.add_router("/ticket-drafts", ticket_drafts_router) api.add_router("/ticket-drafts", ticket_drafts_router)
api.add_router("/tasks", work_items_router)
api.add_router("/solutions", solutions_router) api.add_router("/solutions", solutions_router)
api.add_router("/knowledge", knowledge_router) api.add_router("/knowledge", knowledge_router)
api.add_router("/modelhub", modelhub_router) api.add_router("/modelhub", modelhub_router)
api.add_router("/skills", skills_router) api.add_router("/skills", skills_router)
api.add_router("/agents", agents_router) api.add_router("/agents", agents_router)
api.add_router("/agent-runs", agent_runs_router) api.add_router("/agent-runs", agent_runs_router)
api.add_router("/tools", tools_router)
api.add_router("/channels", channels_router)
urlpatterns = [ urlpatterns = [
path("internal-mcp", internal_mcp_view),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("api/", api.urls), path("api/", api.urls),
] ]

View File

@@ -33,6 +33,7 @@ const TicketDetailPage = React.lazy(() => import('@/features/tickets/TicketDetai
const KnowledgeBaseListPage = React.lazy(() => import('@/features/knowledge/KnowledgeBaseListPage').then((module) => ({ default: module.KnowledgeBaseListPage }))) const KnowledgeBaseListPage = React.lazy(() => import('@/features/knowledge/KnowledgeBaseListPage').then((module) => ({ default: module.KnowledgeBaseListPage })))
const KnowledgeSearchPage = React.lazy(() => import('@/features/knowledge/KnowledgeSearchPage').then((module) => ({ default: module.KnowledgeSearchPage }))) const KnowledgeSearchPage = React.lazy(() => import('@/features/knowledge/KnowledgeSearchPage').then((module) => ({ default: module.KnowledgeSearchPage })))
const KnowledgeItemEditorPage = React.lazy(() => import('@/features/knowledge/KnowledgeItemEditorPage').then((module) => ({ default: module.KnowledgeItemEditorPage }))) const KnowledgeItemEditorPage = React.lazy(() => import('@/features/knowledge/KnowledgeItemEditorPage').then((module) => ({ default: module.KnowledgeItemEditorPage })))
const KnowledgeGovernancePage = React.lazy(() => import('@/features/knowledge/KnowledgeGovernancePage').then((module) => ({ default: module.KnowledgeGovernancePage })))
const SolutionListPage = React.lazy(() => import('@/features/solutions/SolutionListPage').then((module) => ({ default: module.SolutionListPage }))) const SolutionListPage = React.lazy(() => import('@/features/solutions/SolutionListPage').then((module) => ({ default: module.SolutionListPage })))
const SolutionCreatePage = React.lazy(() => import('@/features/solutions/SolutionCreatePage').then((module) => ({ default: module.SolutionCreatePage }))) const SolutionCreatePage = React.lazy(() => import('@/features/solutions/SolutionCreatePage').then((module) => ({ default: module.SolutionCreatePage })))
const ModelProviderPage = React.lazy(() => import('@/features/modelhub/ModelProviderPage').then((module) => ({ default: module.ModelProviderPage }))) const ModelProviderPage = React.lazy(() => import('@/features/modelhub/ModelProviderPage').then((module) => ({ default: module.ModelProviderPage })))
@@ -42,10 +43,13 @@ const AgentListPage = React.lazy(() => import('@/features/agents/AgentListPage')
const AgentDetailPage = React.lazy(() => import('@/features/agents/AgentDetailPage').then((module) => ({ default: module.AgentDetailPage }))) const AgentDetailPage = React.lazy(() => import('@/features/agents/AgentDetailPage').then((module) => ({ default: module.AgentDetailPage })))
const AgentRunListPage = React.lazy(() => import('@/features/agents/AgentRunListPage').then((module) => ({ default: module.AgentRunListPage }))) const AgentRunListPage = React.lazy(() => import('@/features/agents/AgentRunListPage').then((module) => ({ default: module.AgentRunListPage })))
const AgentRunDetailPage = React.lazy(() => import('@/features/agents/AgentRunDetailPage').then((module) => ({ default: module.AgentRunDetailPage }))) const AgentRunDetailPage = React.lazy(() => import('@/features/agents/AgentRunDetailPage').then((module) => ({ default: module.AgentRunDetailPage })))
const ToolManagementPage = React.lazy(() => import('@/features/tools/ToolManagementPage').then((module) => ({ default: module.ToolManagementPage })))
const ChannelManagementPage = React.lazy(() => import('@/features/channels/ChannelManagementPage').then((module) => ({ default: module.ChannelManagementPage })))
const SystemUsersPage = React.lazy(() => import('@/features/system/SystemUsersPage').then((module) => ({ default: module.SystemUsersPage }))) const SystemUsersPage = React.lazy(() => import('@/features/system/SystemUsersPage').then((module) => ({ default: module.SystemUsersPage })))
const SystemUserGroupsPage = React.lazy(() => import('@/features/system/SystemUserGroupsPage').then((module) => ({ default: module.SystemUserGroupsPage }))) const SystemUserGroupsPage = React.lazy(() => import('@/features/system/SystemUserGroupsPage').then((module) => ({ default: module.SystemUserGroupsPage })))
const SystemRolesPage = React.lazy(() => import('@/features/system/SystemRolesPage').then((module) => ({ default: module.SystemRolesPage }))) const SystemRolesPage = React.lazy(() => import('@/features/system/SystemRolesPage').then((module) => ({ default: module.SystemRolesPage })))
const SystemMenusPage = React.lazy(() => import('@/features/system/SystemMenusPage').then((module) => ({ default: module.SystemMenusPage }))) const SystemMenusPage = React.lazy(() => import('@/features/system/SystemMenusPage').then((module) => ({ default: module.SystemMenusPage })))
const SystemIdentityPage = React.lazy(() => import('@/features/system/SystemIdentityPage').then((module) => ({ default: module.SystemIdentityPage })))
const AuditLogPage = React.lazy(() => import('@/features/system/AuditLogPage').then((module) => ({ default: module.AuditLogPage }))) const AuditLogPage = React.lazy(() => import('@/features/system/AuditLogPage').then((module) => ({ default: module.AuditLogPage })))
const DesignSystemPreview = React.lazy(() => import('@/features/design-system/DesignSystemPreview').then((module) => ({ default: module.DesignSystemPreview }))) const DesignSystemPreview = React.lazy(() => import('@/features/design-system/DesignSystemPreview').then((module) => ({ default: module.DesignSystemPreview })))
const ProfilePage = React.lazy(() => import('@/features/profile/ProfilePage').then((module) => ({ default: module.ProfilePage }))) const ProfilePage = React.lazy(() => import('@/features/profile/ProfilePage').then((module) => ({ default: module.ProfilePage })))
@@ -53,11 +57,12 @@ const ProfilePage = React.lazy(() => import('@/features/profile/ProfilePage').th
export const routeMeta: RouteMeta[] = [ export const routeMeta: RouteMeta[] = [
{ key: 'workbench', pattern: /^\/$/, title: '工作台', description: '聚合待办、风险与最近处理进展', section: '工作台', navPath: '/', breadcrumbs: [], agent: 'docked', render: () => <WorkbenchPage /> }, { key: 'workbench', pattern: /^\/$/, title: '工作台', description: '聚合待办、风险与最近处理进展', section: '工作台', navPath: '/', breadcrumbs: [], agent: 'docked', render: () => <WorkbenchPage /> },
{ key: 'profile', pattern: /^\/profile$/, title: '个人中心', description: '查看个人工作情况并维护账号设置', section: '个人中心', navPath: '/profile', breadcrumbs: [{ label: '显示与账户' }], agent: 'on-demand', render: () => <ProfilePage /> }, { key: 'profile', pattern: /^\/profile$/, title: '个人中心', description: '查看个人工作情况并维护账号设置', section: '个人中心', navPath: '/profile', breadcrumbs: [{ label: '显示与账户' }], agent: 'on-demand', render: () => <ProfilePage /> },
{ key: 'ticket-create', pattern: /^\/tickets\/new$/, title: '创建工单', description: '记录问题上下文并进入支持流程', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], agent: 'docked', render: () => <TicketCreatePage /> }, { key: 'ticket-create', pattern: /^\/tickets\/new(?:\?.*)?$/, title: '创建工单', description: '记录问题上下文并进入支持流程', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], agent: 'docked', render: () => <TicketCreatePage /> },
{ key: 'ticket-detail', pattern: /^\/tickets\/(\d+)$/, title: '工单详情', description: '处理状态、方案与协作记录', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], backPath: '/tickets', agent: 'docked', render: (match) => <TicketDetailPage ticketId={Number(match[1])} /> }, { key: 'ticket-detail', pattern: /^\/tickets\/(\d+)$/, title: '工单详情', description: '处理状态、方案与协作记录', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单中心', path: '/tickets' }], backPath: '/tickets', agent: 'docked', render: (match) => <TicketDetailPage ticketId={Number(match[1])} /> },
{ key: 'tickets', pattern: /^\/tickets(?:\?.*)?$/, title: '工单中心', description: '统一查看、筛选和推进支持工单', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单' }], agent: 'docked', render: () => <TicketListPage /> }, { key: 'tickets', pattern: /^\/tickets(?:\?.*)?$/, title: '工单中心', description: '统一查看、筛选和推进支持工单', section: '工单', navPath: '/tickets', breadcrumbs: [{ label: '工单' }], agent: 'docked', render: () => <TicketListPage /> },
{ key: 'knowledge-search', pattern: /^\/knowledge\/search$/, title: '知识检索', description: '跨知识条目与解决方案检索处理依据', section: '知识与方案', navPath: '/knowledge/search', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <KnowledgeSearchPage /> }, { key: 'knowledge-search', pattern: /^\/knowledge\/search$/, title: '知识检索', description: '跨知识条目与解决方案检索处理依据', section: '知识与方案', navPath: '/knowledge/search', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <KnowledgeSearchPage /> },
{ key: 'knowledge-create', pattern: /^\/knowledge\/items\/new$/, title: '新建知识条目', description: '沉淀可复用的支持知识', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => <KnowledgeItemEditorPage /> }, { key: 'knowledge-create', pattern: /^\/knowledge\/items\/new$/, title: '新建知识条目', description: '沉淀可复用的支持知识', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => <KnowledgeItemEditorPage /> },
{ key: 'knowledge-governance', pattern: /^\/knowledge\/governance$/, title: '知识治理', description: '审核资产、追踪版本与查看影响范围', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识库', path: '/knowledge' }], backPath: '/knowledge', agent: 'docked', render: () => <KnowledgeGovernancePage /> },
{ key: 'knowledge', pattern: /^\/knowledge$/, title: '知识库', description: '集中管理业务知识、文档与检索范围', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <KnowledgeBaseListPage /> }, { key: 'knowledge', pattern: /^\/knowledge$/, title: '知识库', description: '集中管理业务知识、文档与检索范围', section: '知识与方案', navPath: '/knowledge', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <KnowledgeBaseListPage /> },
{ key: 'solution-create', pattern: /^\/solutions\/new(?:\?.*)?$/, title: '新建解决方案', description: '将验证过的处理过程沉淀为方案', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '解决方案库', path: '/solutions' }], backPath: '/solutions', agent: 'docked', render: () => <SolutionCreatePage /> }, { key: 'solution-create', pattern: /^\/solutions\/new(?:\?.*)?$/, title: '新建解决方案', description: '将验证过的处理过程沉淀为方案', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '解决方案库', path: '/solutions' }], backPath: '/solutions', agent: 'docked', render: () => <SolutionCreatePage /> },
{ key: 'solutions', pattern: /^\/solutions$/, title: '解决方案库', description: '复用已验证的处置流程和回复依据', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <SolutionListPage /> }, { key: 'solutions', pattern: /^\/solutions$/, title: '解决方案库', description: '复用已验证的处置流程和回复依据', section: '知识与方案', navPath: '/solutions', breadcrumbs: [{ label: '知识与方案' }], agent: 'docked', render: () => <SolutionListPage /> },
@@ -68,10 +73,13 @@ export const routeMeta: RouteMeta[] = [
{ key: 'agents', pattern: /^\/agents$/, title: 'Agent 管理', description: '管理面向支持场景的智能助手', section: 'AI 平台', navPath: '/agents', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <AgentListPage /> }, { key: 'agents', pattern: /^\/agents$/, title: 'Agent 管理', description: '管理面向支持场景的智能助手', section: 'AI 平台', navPath: '/agents', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <AgentListPage /> },
{ key: 'agent-run-detail', pattern: /^\/agent-runs\/([0-9a-f-]+)$/i, title: '运行记录详情', description: '查看一次 Agent 运行的输入、输出与状态', section: 'AI 平台', navPath: '/agent-runs', breadcrumbs: [{ label: 'Agent 运行记录', path: '/agent-runs' }], backPath: '/agent-runs', agent: 'on-demand', render: (match) => <AgentRunDetailPage publicId={match[1]} /> }, { key: 'agent-run-detail', pattern: /^\/agent-runs\/([0-9a-f-]+)$/i, title: '运行记录详情', description: '查看一次 Agent 运行的输入、输出与状态', section: 'AI 平台', navPath: '/agent-runs', breadcrumbs: [{ label: 'Agent 运行记录', path: '/agent-runs' }], backPath: '/agent-runs', agent: 'on-demand', render: (match) => <AgentRunDetailPage publicId={match[1]} /> },
{ key: 'agent-runs', pattern: /^\/agent-runs$/, title: 'Agent 运行记录', description: '追踪智能处理任务的执行结果', section: 'AI 平台', navPath: '/agent-runs', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <AgentRunListPage /> }, { key: 'agent-runs', pattern: /^\/agent-runs$/, title: 'Agent 运行记录', description: '追踪智能处理任务的执行结果', section: 'AI 平台', navPath: '/agent-runs', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <AgentRunListPage /> },
{ key: 'tools', pattern: /^\/tools$/, title: '工具治理', description: '统一管理 Tool、MCP、授权和调用风险', section: 'AI 平台', navPath: '/tools', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <ToolManagementPage /> },
{ key: 'channels', pattern: /^\/channels$/, title: '渠道接入', description: '管理服务群消息的幂等收发与回执', section: 'AI 平台', navPath: '/channels', breadcrumbs: [{ label: 'AI 平台' }], agent: 'on-demand', render: () => <ChannelManagementPage /> },
{ key: 'system-users', pattern: /^\/system\/users$/, title: '用户管理', description: '维护平台账号与可用状态', section: '系统治理', navPath: '/system/users', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemUsersPage /> }, { key: 'system-users', pattern: /^\/system\/users$/, title: '用户管理', description: '维护平台账号与可用状态', section: '系统治理', navPath: '/system/users', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemUsersPage /> },
{ key: 'system-groups', pattern: /^\/system\/user-groups$/, title: '用户组管理', description: '按组织和职责管理授权范围', section: '系统治理', navPath: '/system/user-groups', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemUserGroupsPage /> }, { key: 'system-groups', pattern: /^\/system\/user-groups$/, title: '用户组管理', description: '按组织和职责管理授权范围', section: '系统治理', navPath: '/system/user-groups', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemUserGroupsPage /> },
{ key: 'system-roles', pattern: /^\/system\/roles$/, title: '角色管理', description: '维护角色与权限集合', section: '系统治理', navPath: '/system/roles', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemRolesPage /> }, { key: 'system-roles', pattern: /^\/system\/roles$/, title: '角色管理', description: '维护角色与权限集合', section: '系统治理', navPath: '/system/roles', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemRolesPage /> },
{ key: 'system-menus', pattern: /^\/system\/menus$/, title: '菜单权限', description: '管理导航可见性和功能权限', section: '系统治理', navPath: '/system/menus', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemMenusPage /> }, { key: 'system-menus', pattern: /^\/system\/menus$/, title: '菜单权限', description: '管理导航可见性和功能权限', section: '系统治理', navPath: '/system/menus', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemMenusPage /> },
{ key: 'system-identity', pattern: /^\/system\/identity$/, title: '企业身份', description: '管理单点登录、外部身份与组织同步', section: '系统治理', navPath: '/system/identity', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <SystemIdentityPage /> },
{ key: 'audit', pattern: /^\/audit$/, title: '审计日志', description: '追踪关键配置与业务操作记录', section: '系统治理', navPath: '/audit', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <AuditLogPage /> }, { key: 'audit', pattern: /^\/audit$/, title: '审计日志', description: '追踪关键配置与业务操作记录', section: '系统治理', navPath: '/audit', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <AuditLogPage /> },
{ key: 'design-system', pattern: /^\/design-system$/, title: '设计系统预览', description: '检查基础组件和语义状态', section: '系统治理', navPath: '/design-system', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <DesignSystemPreview /> }, { key: 'design-system', pattern: /^\/design-system$/, title: '设计系统预览', description: '检查基础组件和语义状态', section: '系统治理', navPath: '/design-system', breadcrumbs: [{ label: '系统治理' }], agent: 'on-demand', render: () => <DesignSystemPreview /> },
] ]

View File

@@ -22,6 +22,7 @@ type AppCreateDialogProps = {
onSubmit: React.FormEventHandler<HTMLFormElement> onSubmit: React.FormEventHandler<HTMLFormElement>
children: React.ReactNode children: React.ReactNode
pending?: boolean pending?: boolean
pendingLabel?: string
submitDisabled?: boolean submitDisabled?: boolean
error?: string error?: string
className?: string className?: string
@@ -37,6 +38,7 @@ export function AppCreateDialog({
onSubmit, onSubmit,
children, children,
pending = false, pending = false,
pendingLabel = '正在创建…',
submitDisabled = false, submitDisabled = false,
error, error,
className, className,
@@ -69,7 +71,7 @@ export function AppCreateDialog({
</Button> </Button>
<Button type="submit" disabled={pending || submitDisabled}> <Button type="submit" disabled={pending || submitDisabled}>
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" /> : <Plus data-icon="inline-start" />} {pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" /> : <Plus data-icon="inline-start" />}
{pending ? '正在创建…' : submitLabel} {pending ? pendingLabel : submitLabel}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View File

@@ -12,11 +12,19 @@ function navigate(path: string) {
} }
beforeEach(() => { beforeEach(() => {
vi.stubGlobal('ResizeObserver', class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
})
window.sessionStorage.clear() window.sessionStorage.clear()
window.location.hash = '#/tickets/42' window.location.hash = '#/tickets/42'
}) })
afterEach(() => cleanup()) afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('已打开页面标签', () => { describe('已打开页面标签', () => {
it('只显示实际打开过的页面,并允许关闭非当前标签', async () => { it('只显示实际打开过的页面,并允许关闭非当前标签', async () => {
@@ -64,6 +72,47 @@ describe('已打开页面标签', () => {
expect(screen.getByRole('tab', { name: '知识库' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: '知识库' })).toBeInTheDocument()
}) })
it('右键标签可以关闭其左侧和右侧标签', async () => {
const user = userEvent.setup()
render(<AppRouteTabs />)
navigate('/knowledge')
navigate('/solutions')
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(3))
fireEvent.contextMenu(screen.getByRole('tab', { name: '知识库' }))
await user.click(await screen.findByRole('menuitem', { name: '关闭左侧标签' }))
expect(screen.queryByRole('tab', { name: '工单详情' })).not.toBeInTheDocument()
expect(screen.getByRole('tab', { name: '知识库' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: '解决方案库' })).toBeInTheDocument()
fireEvent.contextMenu(screen.getByRole('tab', { name: '知识库' }))
await user.click(await screen.findByRole('menuitem', { name: '关闭右侧标签' }))
expect(screen.queryByRole('tab', { name: '解决方案库' })).not.toBeInTheDocument()
expect(screen.getAllByRole('tab')).toHaveLength(1)
expect(window.location.hash).toBe('#/knowledge')
})
it('批量菜单可以关闭其他标签和关闭全部标签', async () => {
const user = userEvent.setup()
render(<AppRouteTabs />)
navigate('/knowledge')
navigate('/solutions')
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(3))
await user.click(screen.getByRole('button', { name: '查看全部已打开页面,共 3 个' }))
await user.click(screen.getByRole('menuitem', { name: '关闭其他标签' }))
expect(screen.getAllByRole('tab')).toHaveLength(1)
expect(screen.getByRole('tab', { name: '解决方案库' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '查看全部已打开页面,共 1 个' }))
await user.click(screen.getByRole('menuitem', { name: '关闭全部标签' }))
expect(screen.getAllByRole('tab')).toHaveLength(1)
expect(screen.getByRole('tab', { name: '工作台' })).toBeInTheDocument()
expect(window.location.hash).toBe('#/')
})
it('标签溢出时隐藏原生滚动条,并提供左右切换和全部页面菜单', async () => { it('标签溢出时隐藏原生滚动条,并提供左右切换和全部页面菜单', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const { container } = render(<AppRouteTabs />) const { container } = render(<AppRouteTabs />)

View File

@@ -1,5 +1,5 @@
import * as React from 'react' import * as React from 'react'
import { Check, ChevronLeft, ChevronRight, Ellipsis, X } from 'lucide-react' import { Check, ChevronLeft, ChevronRight, Ellipsis, Layers2, PanelLeftClose, PanelRightClose, Trash2, X } from 'lucide-react'
import { useCurrentRoute, type RouteNavigationTab } from '@/app/route-meta' import { useCurrentRoute, type RouteNavigationTab } from '@/app/route-meta'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -9,14 +9,26 @@ import {
DropdownMenuGroup, DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
const STORAGE_KEY = 'ai-ticket-platform.opened-route-tabs.v2' const STORAGE_KEY = 'ai-ticket-platform.opened-route-tabs.v2'
const MAX_OPEN_TABS = 12 const MAX_OPEN_TABS = 12
const SCROLL_EPSILON = 2 const SCROLL_EPSILON = 2
const WORKBENCH_TAB: RouteNavigationTab = { label: '工作台', path: '/' }
type CloseScope = 'left' | 'right' | 'others' | 'all'
type TabScrollState = { type TabScrollState = {
overflow: boolean overflow: boolean
@@ -60,6 +72,7 @@ export function AppRouteTabs() {
canScrollLeft: false, canScrollLeft: false,
canScrollRight: false canScrollRight: false
}) })
const activeTabIndex = openedTabs.findIndex((tab) => tab.path === route)
const syncScrollState = React.useCallback(() => { const syncScrollState = React.useCallback(() => {
const element = scrollAreaRef.current const element = scrollAreaRef.current
@@ -124,13 +137,33 @@ export function AppRouteTabs() {
const closeTab = (path: string) => { const closeTab = (path: string) => {
const closedIndex = openedTabs.findIndex((tab) => tab.path === path) const closedIndex = openedTabs.findIndex((tab) => tab.path === path)
const next = openedTabs.filter((tab) => tab.path !== path) const remaining = openedTabs.filter((tab) => tab.path !== path)
const next = remaining.length ? remaining : [WORKBENCH_TAB]
saveOpenedTabs(next) saveOpenedTabs(next)
setOpenedTabs(next) setOpenedTabs(next)
if (path !== route) return if (path !== route) return
const fallback = next[Math.max(0, closedIndex - 1)] ?? next[0] const fallback = remaining[Math.max(0, closedIndex - 1)] ?? remaining[0] ?? WORKBENCH_TAB
window.location.hash = fallback?.path ?? '/' window.location.hash = fallback.path
}
const closeTabs = (anchorPath: string, scope: CloseScope) => {
const anchorIndex = openedTabs.findIndex((tab) => tab.path === anchorPath)
if (anchorIndex < 0 && scope !== 'all') return
let remaining: RouteNavigationTab[]
if (scope === 'left') remaining = openedTabs.slice(anchorIndex)
else if (scope === 'right') remaining = openedTabs.slice(0, anchorIndex + 1)
else if (scope === 'others') remaining = [openedTabs[anchorIndex]]
else remaining = []
const next = remaining.length ? remaining : [WORKBENCH_TAB]
saveOpenedTabs(next)
setOpenedTabs(next)
if (next.some((tab) => tab.path === route)) return
const anchor = next.find((tab) => tab.path === anchorPath)
window.location.hash = anchor?.path ?? WORKBENCH_TAB.path
} }
const scrollTabs = (direction: -1 | 1) => { const scrollTabs = (direction: -1 | 1) => {
@@ -175,39 +208,53 @@ export function AppRouteTabs() {
event.currentTarget.scrollLeft += event.deltaY event.currentTarget.scrollLeft += event.deltaY
}} }}
> >
{openedTabs.map((tab) => ( {openedTabs.map((tab, tabIndex) => (
<div <ContextMenu key={tab.path}>
key={tab.path} <ContextMenuTrigger asChild>
ref={(node) => { <div
if (node) tabRefs.current.set(tab.path, node) ref={(node) => {
else tabRefs.current.delete(tab.path) if (node) tabRefs.current.set(tab.path, node)
}} else tabRefs.current.delete(tab.path)
className="group/tab relative flex flex-none items-center" }}
> className="group/tab relative flex flex-none items-center"
<TabsTrigger value={tab.path} className="max-w-52 flex-none px-3.5 pr-8"> >
<span className="truncate">{tab.label}</span> <TabsTrigger value={tab.path} className="max-w-52 flex-none px-3.5 pr-8">
</TabsTrigger> <span className="truncate">{tab.label}</span>
<Button </TabsTrigger>
type="button" <Button
variant="ghost" type="button"
size="icon-xs" variant="ghost"
className="absolute right-1 opacity-60 group-hover/tab:opacity-100 focus-visible:opacity-100" size="icon-xs"
aria-label={`关闭${tab.label}(${tab.path})`} className="absolute right-1 opacity-60 group-hover/tab:opacity-100 focus-visible:opacity-100"
title={`关闭${tab.label}`} aria-label={`关闭${tab.label}(${tab.path})`}
onPointerDown={(event) => event.stopPropagation()} title={`关闭${tab.label}`}
onClick={(event) => { onPointerDown={(event) => event.stopPropagation()}
event.stopPropagation() onClick={(event) => {
closeTab(tab.path) event.stopPropagation()
}} closeTab(tab.path)
> }}
<X /> >
</Button> <X />
</div> </Button>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">
<ContextMenuGroup>
<ContextMenuItem onSelect={() => closeTab(tab.path)}><X />关闭当前标签</ContextMenuItem>
<ContextMenuItem disabled={tabIndex === 0} onSelect={() => closeTabs(tab.path, 'left')}><PanelLeftClose />关闭左侧标签</ContextMenuItem>
<ContextMenuItem disabled={tabIndex === openedTabs.length - 1} onSelect={() => closeTabs(tab.path, 'right')}><PanelRightClose />关闭右侧标签</ContextMenuItem>
<ContextMenuItem disabled={openedTabs.length <= 1} onSelect={() => closeTabs(tab.path, 'others')}><Layers2 />关闭其他标签</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem onSelect={() => closeTabs(tab.path, 'all')}><Trash2 />关闭全部标签</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
))} ))}
</TabsList> </TabsList>
</Tabs> </Tabs>
{scrollState.overflow ? ( {scrollState.overflow ? (
<>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -218,33 +265,40 @@ export function AppRouteTabs() {
> >
<ChevronRight /> <ChevronRight />
</Button> </Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`查看全部已打开页面,共 ${openedTabs.length} 个`}>
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="scrollbar-panel max-h-80 w-64">
<DropdownMenuLabel>已打开页面 · {openedTabs.length}</DropdownMenuLabel>
<DropdownMenuGroup>
{openedTabs.map((tab) => (
<DropdownMenuItem
key={tab.path}
aria-current={tab.path === route ? 'page' : undefined}
onSelect={() => {
if (tab.path !== route) window.location.hash = tab.path
}}
>
{tab.path === route ? <Check /> : <span aria-hidden="true" className="size-4" />}
<span className="truncate">{tab.label}</span>
{tab.path === route ? <DropdownMenuShortcut>当前</DropdownMenuShortcut> : null}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</>
) : null} ) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`查看全部已打开页面,共 ${openedTabs.length} 个`} title="管理已打开页面">
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="scrollbar-panel max-h-80 w-64">
<DropdownMenuGroup>
<DropdownMenuLabel>已打开页面 · {openedTabs.length}</DropdownMenuLabel>
{openedTabs.map((tab) => (
<DropdownMenuItem
key={tab.path}
aria-current={tab.path === route ? 'page' : undefined}
onSelect={() => {
if (tab.path !== route) window.location.hash = tab.path
}}
>
{tab.path === route ? <Check /> : <span aria-hidden="true" className="size-4" />}
<span className="truncate">{tab.label}</span>
{tab.path === route ? <DropdownMenuShortcut>当前</DropdownMenuShortcut> : null}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>批量关闭</DropdownMenuLabel>
<DropdownMenuItem disabled={activeTabIndex <= 0} onSelect={() => closeTabs(route, 'left')}><PanelLeftClose />关闭左侧标签</DropdownMenuItem>
<DropdownMenuItem disabled={activeTabIndex < 0 || activeTabIndex === openedTabs.length - 1} onSelect={() => closeTabs(route, 'right')}><PanelRightClose />关闭右侧标签</DropdownMenuItem>
<DropdownMenuItem disabled={openedTabs.length <= 1} onSelect={() => closeTabs(route, 'others')}><Layers2 />关闭其他标签</DropdownMenuItem>
<DropdownMenuItem onSelect={() => closeTabs(route, 'all')}><Trash2 />关闭全部标签</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</nav> </nav>
) )
} }

View File

@@ -10,6 +10,7 @@ import {
Library, Library,
LogOut, LogOut,
Menu, Menu,
MessageSquareMore,
ScrollText, ScrollText,
Search, Search,
Settings, Settings,
@@ -56,6 +57,7 @@ const iconMap = {
home: Home, home: Home,
library: Library, library: Library,
menu: Menu, menu: Menu,
message: MessageSquareMore,
roles: UserCog, roles: UserCog,
search: Search, search: Search,
settings: Settings, settings: Settings,

View File

@@ -0,0 +1,55 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LoginPage } from './LoginPage'
const mocks = vi.hoisted(() => ({
login: vi.fn(),
listIdentityLoginProviders: vi.fn(),
startIdentityLogin: vi.fn()
}))
vi.mock('./AuthProvider', () => ({
useAuth: () => ({ login: mocks.login })
}))
vi.mock('./api', () => ({
listIdentityLoginProviders: mocks.listIdentityLoginProviders,
startIdentityLogin: mocks.startIdentityLogin
}))
describe('LoginPage enterprise identity', () => {
afterEach(cleanup)
beforeEach(() => {
vi.clearAllMocks()
window.location.hash = ''
mocks.listIdentityLoginProviders.mockResolvedValue([
{
slug: 'corp',
name: '企业统一身份',
button_label: '使用企业账号登录',
provider_type: 'oidc'
}
])
})
it('shows enabled OIDC providers and starts enterprise login', async () => {
render(<LoginPage />)
const ssoButton = await screen.findByRole('button', { name: '使用企业账号登录' })
fireEvent.click(ssoButton)
expect(mocks.startIdentityLogin).toHaveBeenCalledWith('corp')
expect(screen.getByText('或使用本地账号')).toBeInTheDocument()
})
it('surfaces callback error without hiding local login', async () => {
window.location.hash = '#/login?auth_error=企业登录失败'
render(<LoginPage />)
await waitFor(() => expect(screen.getByText('企业登录失败')).toBeInTheDocument())
expect(screen.getByPlaceholderText('请输入账号')).toBeInTheDocument()
expect(screen.getByPlaceholderText('请输入密码')).toBeInTheDocument()
})
})

View File

@@ -1,13 +1,15 @@
import * as React from 'react' import * as React from 'react'
import { AlertCircle, CheckCircle2, LockKeyhole, Sparkles, UserRound, Workflow } from 'lucide-react' import { AlertCircle, Building2, CheckCircle2, LockKeyhole, Sparkles, UserRound, Workflow } from 'lucide-react'
import { AppField } from '@/components/app/AppField' import { AppField } from '@/components/app/AppField'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { FieldSeparator } from '@/components/ui/field'
import { useAuth } from './AuthProvider' import { useAuth } from './AuthProvider'
import { listIdentityLoginProviders, startIdentityLogin, type IdentityLoginProvider } from './api'
export function LoginPage() { export function LoginPage() {
const { login } = useAuth() const { login } = useAuth()
@@ -15,6 +17,14 @@ export function LoginPage() {
const [password, setPassword] = React.useState('') const [password, setPassword] = React.useState('')
const [error, setError] = React.useState('') const [error, setError] = React.useState('')
const [submitting, setSubmitting] = React.useState(false) const [submitting, setSubmitting] = React.useState(false)
const [providers, setProviders] = React.useState<IdentityLoginProvider[]>([])
React.useEffect(() => {
void listIdentityLoginProviders().then(setProviders).catch(() => setProviders([]))
const query = window.location.hash.split('?')[1]
const authError = query ? new URLSearchParams(query).get('auth_error') : ''
if (authError) setError(authError)
}, [])
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
@@ -43,6 +53,24 @@ export function LoginPage() {
<CardContent className="px-6"> <CardContent className="px-6">
<form className="grid gap-4" onSubmit={handleSubmit}> <form className="grid gap-4" onSubmit={handleSubmit}>
{error ? <Alert variant="destructive"><AlertCircle className="size-4" /><AlertTitle>无法登录</AlertTitle><AlertDescription>{error}</AlertDescription></Alert> : null} {error ? <Alert variant="destructive"><AlertCircle className="size-4" /><AlertTitle>无法登录</AlertTitle><AlertDescription>{error}</AlertDescription></Alert> : null}
{providers.length ? (
<div className="flex flex-col gap-3">
{providers.map((provider) => (
<Button
key={provider.slug}
type="button"
size="lg"
variant="outline"
className="w-full justify-start"
onClick={() => startIdentityLogin(provider.slug)}
>
<Building2 data-icon="inline-start" />
{provider.button_label}
</Button>
))}
<FieldSeparator>或使用本地账号</FieldSeparator>
</div>
) : null}
<AppField label="账号" htmlFor="username" invalid={Boolean(error)} required><div className="relative"><UserRound className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input id="username" autoComplete="username" aria-invalid={Boolean(error)} className="pl-9" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="请输入账号" /></div></AppField> <AppField label="账号" htmlFor="username" invalid={Boolean(error)} required><div className="relative"><UserRound className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input id="username" autoComplete="username" aria-invalid={Boolean(error)} className="pl-9" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="请输入账号" /></div></AppField>
<AppField label="密码" htmlFor="password" invalid={Boolean(error)} required><div className="relative"><LockKeyhole className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input id="password" type="password" autoComplete="current-password" aria-invalid={Boolean(error)} className="pl-9" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" /></div></AppField> <AppField label="密码" htmlFor="password" invalid={Boolean(error)} required><div className="relative"><LockKeyhole className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input id="password" type="password" autoComplete="current-password" aria-invalid={Boolean(error)} className="pl-9" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" /></div></AppField>
<Button type="submit" size="lg" className="mt-1 w-full" disabled={submitting || !username || !password}>{submitting ? '登录中...' : '登录'}</Button> <Button type="submit" size="lg" className="mt-1 w-full" disabled={submitting || !username || !password}>{submitting ? '登录中...' : '登录'}</Button>

View File

@@ -1,7 +1,14 @@
import { apiRequest } from '@/lib/api' import { apiRequest, apiUrl } from '@/lib/api'
import type { CurrentUser } from './types' import type { CurrentUser } from './types'
export type IdentityLoginProvider = {
slug: string
name: string
button_label: string
provider_type: 'oidc'
}
export async function getCurrentUser() { export async function getCurrentUser() {
return apiRequest<CurrentUser>('/accounts/auth/me') return apiRequest<CurrentUser>('/accounts/auth/me')
} }
@@ -32,3 +39,12 @@ export async function logout() {
method: 'POST' method: 'POST'
}) })
} }
export function listIdentityLoginProviders() {
return apiRequest<IdentityLoginProvider[]>('/identity/auth/providers')
}
export function startIdentityLogin(slug: string, returnTo = '/') {
const query = new URLSearchParams({ return_to: returnTo })
window.location.assign(apiUrl(`/identity/auth/oidc/${encodeURIComponent(slug)}/start?${query}`))
}

View File

@@ -0,0 +1,109 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ChannelManagementPage } from './ChannelManagementPage'
import {
listChannelEndpoints,
listChannelMessages,
retryChannelMessage
} from './api'
vi.mock('@/features/auth/AuthProvider', () => ({
useAuth: () => ({
user: {
is_superuser: true,
permissions: ['channel.view', 'channel.send', 'channel.manage']
}
})
}))
vi.mock('./api', () => ({
listChannelEndpoints: vi.fn(),
listChannelMessages: vi.fn(),
createChannelEndpoint: vi.fn(),
updateChannelEndpoint: vi.fn(),
sendChannelMessage: vi.fn(),
retryChannelMessage: vi.fn()
}))
const endpoint = {
id: 1,
public_id: 'endpoint-public-id',
name: '一线服务群',
adapter_type: 'generic_webhook' as const,
outbound_url: 'https://channel.example/messages',
webhook_secret_set: true,
outbound_token_set: true,
enabled: true,
created_at: '2026-07-27T08:00:00Z',
updated_at: '2026-07-27T08:00:00Z'
}
const failedMessage = {
public_id: 'message-public-id',
endpoint_id: 1,
endpoint_name: '一线服务群',
conversation_public_id: 'conversation-public-id',
external_conversation_id: 'group-001',
ticket_id: 42,
customer_reply_task_id: 15,
reply_revision: 2,
direction: 'outbound' as const,
external_message_id: '',
idempotency_key: 'reply-001',
sender: '支持工程师',
message_type: 'text',
text: '正在处理中,请稍候。',
payload: {},
status: 'failed' as const,
error_message: '上游连接超时',
occurred_at: null,
sent_at: null,
delivered_at: null,
created_at: '2026-07-27T08:10:00Z',
updated_at: '2026-07-27T08:10:00Z'
}
describe('ChannelManagementPage', () => {
afterEach(cleanup)
beforeEach(() => {
vi.mocked(listChannelEndpoints).mockResolvedValue([endpoint])
vi.mocked(listChannelMessages).mockResolvedValue([failedMessage])
vi.mocked(retryChannelMessage).mockResolvedValue({
...failedMessage,
status: 'queued',
error_message: ''
})
})
it('展示渠道消息并允许失败出站重试', async () => {
const user = userEvent.setup()
render(<ChannelManagementPage />)
expect(await screen.findByText('正在处理中,请稍候。')).toBeInTheDocument()
expect(screen.getAllByText('一线服务群').length).toBeGreaterThan(0)
expect(screen.getByText('上游连接超时')).toBeInTheDocument()
expect(screen.getByText(/回复任务 #15 v2/)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '发送消息' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() => {
expect(retryChannelMessage).toHaveBeenCalledWith('message-public-id')
})
})
it('展示适配器凭证状态和 Webhook 地址', async () => {
const user = userEvent.setup()
render(<ChannelManagementPage />)
await screen.findByText('正在处理中,请稍候。')
await user.click(screen.getByRole('tab', { name: '适配器' }))
expect(screen.getByText('签名密钥:已配置')).toBeInTheDocument()
expect(screen.getByText('出站凭证:已配置')).toBeInTheDocument()
expect(screen.getByText(/endpoint-public-id/)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,414 @@
import * as React from 'react'
import {
ArrowDownToLine,
ArrowUpFromLine,
CircleAlert,
Copy,
KeyRound,
Plus,
RefreshCw,
RotateCcw,
Webhook
} from 'lucide-react'
import { toast } from 'sonner'
import { AppCreateDialog } from '@/components/app/AppCreateDialog'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppField } from '@/components/app/AppField'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppSection } from '@/components/app/AppSection'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useAuth } from '@/features/auth/AuthProvider'
import { formatDateTime } from '@/lib/format'
import {
createChannelEndpoint,
listChannelEndpoints,
listChannelMessages,
retryChannelMessage,
updateChannelEndpoint
} from './api'
import type { ChannelEndpoint, ChannelMessage, ChannelMessageStatus } from './types'
const endpointInitial = {
name: '',
outbound_url: '',
webhook_secret: '',
outbound_token: ''
}
const STATUS_META: Record<ChannelMessageStatus, {
label: string
variant: 'secondary' | 'info' | 'success' | 'danger' | 'warning'
}> = {
received: { label: '已接收', variant: 'info' },
queued: { label: '待投递', variant: 'warning' },
sent: { label: '已发送', variant: 'success' },
delivered: { label: '已送达', variant: 'success' },
failed: { label: '失败', variant: 'danger' }
}
export function ChannelManagementPage() {
const { user } = useAuth()
const [endpoints, setEndpoints] = React.useState<ChannelEndpoint[]>([])
const [messages, setMessages] = React.useState<ChannelMessage[]>([])
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState('')
const [busy, setBusy] = React.useState('')
const [endpointFilter, setEndpointFilter] = React.useState('all')
const [directionFilter, setDirectionFilter] = React.useState('all')
const [createOpen, setCreateOpen] = React.useState(false)
const [endpointForm, setEndpointForm] = React.useState(endpointInitial)
const hasPermission = React.useCallback((permission: string) => Boolean(
user?.is_superuser || user?.permissions.includes(permission)
), [user])
const canManage = hasPermission('channel.manage')
const canSend = hasPermission('channel.send') && hasPermission('ticket.reply')
const load = React.useCallback(async () => {
setLoading(true)
try {
const endpointId = endpointFilter === 'all' ? undefined : Number(endpointFilter)
const direction = directionFilter === 'all' ? undefined : directionFilter
const [nextEndpoints, nextMessages] = await Promise.all([
listChannelEndpoints(),
listChannelMessages({ endpoint_id: endpointId, direction })
])
setEndpoints(nextEndpoints)
setMessages(nextMessages)
setError('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '渠道数据加载失败')
} finally {
setLoading(false)
}
}, [directionFilter, endpointFilter])
React.useEffect(() => {
void load()
}, [load])
async function runAction(key: string, action: () => Promise<unknown>, success: string) {
setBusy(key)
try {
await action()
toast.success(success)
await load()
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : '操作未完成')
} finally {
setBusy('')
}
}
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setBusy('create')
try {
await createChannelEndpoint({ ...endpointForm, enabled: true })
setEndpointForm(endpointInitial)
setCreateOpen(false)
toast.success('渠道适配器已创建')
await load()
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : '渠道创建失败')
} finally {
setBusy('')
}
}
const messageColumns: AppDataTableColumn<ChannelMessage>[] = [
{
key: 'direction',
header: '方向',
cell: (message) => (
<Badge variant={message.direction === 'inbound' ? 'info' : 'ai'}>
{message.direction === 'inbound' ? <ArrowDownToLine /> : <ArrowUpFromLine />}
{message.direction === 'inbound' ? '入站' : '出站'}
</Badge>
)
},
{
key: 'channel',
header: '渠道 / 会话',
cell: (message) => (
<div>
<p className="font-medium">{message.endpoint_name}</p>
<p className="max-w-48 truncate text-xs text-muted-foreground">
{message.external_conversation_id}
</p>
</div>
)
},
{
key: 'message',
header: '消息',
cell: (message) => (
<div className="max-w-xl">
<p className="line-clamp-2 text-sm">{message.text}</p>
<p className="mt-1 text-xs text-muted-foreground">
{message.sender || '平台'}{message.ticket_id ? ` · 工单 #${message.ticket_id}` : ''}
{message.customer_reply_task_id
? ` · 回复任务 #${message.customer_reply_task_id} v${message.reply_revision}`
: ''}
</p>
</div>
)
},
{
key: 'status',
header: '状态',
cell: (message) => (
<div>
<Badge variant={STATUS_META[message.status].variant}>{STATUS_META[message.status].label}</Badge>
{message.error_message ? (
<p className="mt-1 max-w-44 truncate text-xs text-danger" title={message.error_message}>
{message.error_message}
</p>
) : null}
</div>
)
},
{
key: 'time',
header: '时间',
cell: (message) => (
<span className="whitespace-nowrap text-xs text-muted-foreground">
{formatDateTime(message.occurred_at || message.created_at)}
</span>
)
},
{
key: 'action',
header: '',
cell: (message) => message.direction === 'outbound' && message.status === 'failed' && canSend ? (
<Button
size="sm"
variant="outline"
disabled={busy === `retry-${message.public_id}`}
onClick={() => void runAction(
`retry-${message.public_id}`,
() => retryChannelMessage(message.public_id),
'消息已重新进入投递队列'
)}
>
<RotateCcw data-icon="inline-start" />重试
</Button>
) : null
}
]
if (!hasPermission('channel.view')) {
return (
<AppPage header={<AppPageHeader title="渠道接入" description="管理服务群消息的幂等收发和回执。" />}>
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>没有查看权限</AlertTitle>
<AlertDescription>请联系管理员授予“查看渠道消息”权限。</AlertDescription>
</Alert>
</AppPage>
)
}
const inboundCount = messages.filter((message) => message.direction === 'inbound').length
const outboundCount = messages.filter((message) => message.direction === 'outbound').length
const failedCount = messages.filter((message) => message.status === 'failed').length
const metricCards: Array<{ label: string; value: number; icon: typeof KeyRound }> = [
{ label: '已启用渠道', value: endpoints.filter((endpoint) => endpoint.enabled).length, icon: KeyRound },
{ label: '入站消息', value: inboundCount, icon: ArrowDownToLine },
{ label: '出站消息', value: outboundCount, icon: ArrowUpFromLine },
{ label: '失败待处理', value: failedCount, icon: CircleAlert }
]
return (
<AppPage
maxWidth="7xl"
contentClassName="gap-5"
header={(
<AppPageHeader
title="渠道接入"
description="用统一 Webhook 契约接入服务群,保证入站去重、出站幂等和回执可追溯。"
meta={<Badge variant="ai"><Webhook />Generic Webhook</Badge>}
actions={(
<>
<Button variant="outline" onClick={() => void load()} disabled={loading}>
<RefreshCw data-icon="inline-start" />刷新
</Button>
{canManage ? (
<Button onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />接入渠道
</Button>
) : null}
</>
)}
/>
)}
>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{metricCards.map(({ label, value, icon: Icon }) => (
<Card key={label} size="sm">
<CardHeader>
<CardDescription>{label}</CardDescription>
<div className="flex items-center justify-between">
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
<Icon className="size-5 text-muted-foreground" />
</div>
</CardHeader>
</Card>
))}
</div>
{error ? (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>渠道数据加载失败</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{loading ? (
<div className="grid gap-3"><Skeleton className="h-10 w-72" /><Skeleton className="h-96" /></div>
) : (
<Tabs defaultValue="messages">
<TabsList variant="line">
<TabsTrigger value="messages">消息时间线</TabsTrigger>
<TabsTrigger value="endpoints">适配器</TabsTrigger>
</TabsList>
<TabsContent value="messages" className="mt-4">
<AppSection title="最近收发" description="最多展示最近 100 条消息;失败出站可沿用原幂等键重试。">
<AppDataTable
columns={messageColumns}
rows={messages}
getRowKey={(message) => message.public_id}
emptyTitle="暂无渠道消息"
emptyDescription="签名 Webhook 收到消息后会显示在这里。"
toolbar={(
<div className="flex flex-wrap gap-2">
<Select value={endpointFilter} onValueChange={setEndpointFilter}>
<SelectTrigger className="w-52"><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup>
<SelectItem value="all">全部渠道</SelectItem>
{endpoints.map((endpoint) => (
<SelectItem key={endpoint.id} value={String(endpoint.id)}>{endpoint.name}</SelectItem>
))}
</SelectGroup></SelectContent>
</Select>
<Select value={directionFilter} onValueChange={setDirectionFilter}>
<SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup>
<SelectItem value="all">全部方向</SelectItem>
<SelectItem value="inbound">仅入站</SelectItem>
<SelectItem value="outbound">仅出站</SelectItem>
</SelectGroup></SelectContent>
</Select>
</div>
)}
/>
</AppSection>
</TabsContent>
<TabsContent value="endpoints" className="mt-4">
<div className="grid gap-4 lg:grid-cols-2">
{endpoints.length === 0 ? (
<div className="lg:col-span-2">
<AppEmptyState title="还没有渠道适配器" description="创建适配器后,将签名 Webhook 地址配置到服务群机器人。" />
</div>
) : endpoints.map((endpoint) => {
const webhookUrl = `${window.location.origin}/api/channels/webhooks/${endpoint.public_id}/messages`
return (
<Card key={endpoint.id}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{endpoint.name}</CardTitle>
<CardDescription>{endpoint.adapter_type}</CardDescription>
</div>
<Badge variant={endpoint.enabled ? 'success' : 'secondary'}>
{endpoint.enabled ? '已启用' : '已停用'}
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="rounded-lg bg-muted/45 p-3">
<p className="text-xs text-muted-foreground">入站 Webhook</p>
<div className="mt-1 flex items-center gap-2">
<code className="min-w-0 flex-1 truncate text-xs">{webhookUrl}</code>
<Button
size="icon"
variant="ghost"
aria-label={`复制 ${endpoint.name} Webhook`}
onClick={() => void navigator.clipboard.writeText(webhookUrl).then(() => toast.success('Webhook 已复制'))}
>
<Copy />
</Button>
</div>
</div>
<div className="grid gap-2 text-sm sm:grid-cols-2">
<span>签名密钥:{endpoint.webhook_secret_set ? '已配置' : '未配置'}</span>
<span>出站凭证:{endpoint.outbound_token_set ? '已配置' : '未配置'}</span>
</div>
<p className="truncate text-xs text-muted-foreground">
出站地址:{endpoint.outbound_url || '未配置'}
</p>
{canManage ? (
<div className="flex items-center justify-between border-t pt-3">
<span className="text-sm">接收与发送</span>
<Switch
checked={endpoint.enabled}
disabled={busy === `endpoint-${endpoint.id}`}
onCheckedChange={(enabled) => void runAction(
`endpoint-${endpoint.id}`,
() => updateChannelEndpoint(endpoint.id, { enabled }),
enabled ? '渠道已启用' : '渠道已停用'
)}
/>
</div>
) : null}
</CardContent>
</Card>
)
})}
</div>
</TabsContent>
</Tabs>
)}
<AppCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
title="接入 Generic Webhook"
description="入站请求使用 HMAC-SHA256 签名;访问凭证保存后不再回显。"
submitLabel="创建适配器"
pending={busy === 'create'}
onSubmit={handleCreate}
>
<FieldGroup>
<AppField label="渠道名称" htmlFor="channel-name" required>
<Input id="channel-name" required autoFocus value={endpointForm.name} onChange={(event) => setEndpointForm({ ...endpointForm, name: event.target.value })} placeholder="例如:一线服务群" />
</AppField>
<AppField label="出站地址" htmlFor="channel-url" required>
<Input id="channel-url" required type="url" value={endpointForm.outbound_url} onChange={(event) => setEndpointForm({ ...endpointForm, outbound_url: event.target.value })} placeholder="https://channel.example/messages" />
</AppField>
<AppField label="Webhook 签名密钥" htmlFor="channel-secret" required>
<Input id="channel-secret" required type="password" autoComplete="new-password" value={endpointForm.webhook_secret} onChange={(event) => setEndpointForm({ ...endpointForm, webhook_secret: event.target.value })} />
</AppField>
<AppField label="出站 Bearer Token" htmlFor="channel-token">
<Input id="channel-token" type="password" autoComplete="new-password" value={endpointForm.outbound_token} onChange={(event) => setEndpointForm({ ...endpointForm, outbound_token: event.target.value })} />
</AppField>
</FieldGroup>
</AppCreateDialog>
</AppPage>
)
}

View File

@@ -0,0 +1,115 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { CustomerReplyTask } from '@/features/tickets/types'
import { ChannelReplyDialog } from './ChannelReplyDialog'
import { listChannelEndpoints, sendChannelMessage } from './api'
import type { ChannelEndpoint, ChannelMessage } from './types'
vi.mock('./api', () => ({
listChannelEndpoints: vi.fn(),
sendChannelMessage: vi.fn()
}))
const endpoint: ChannelEndpoint = {
id: 8,
public_id: 'endpoint-8',
name: '客户服务群',
adapter_type: 'generic_webhook',
outbound_url: 'https://channel.example/messages',
webhook_secret_set: true,
outbound_token_set: true,
enabled: true,
created_at: '2026-07-27T08:00:00Z',
updated_at: '2026-07-27T08:00:00Z'
}
const task: CustomerReplyTask = {
id: 15,
ticket_id: 7,
support_analysis_id: 9,
status: 'draft',
draft_text: '我们正在检查订单服务连接池状态。',
final_text: '连接池已恢复,请重新尝试。',
revision: 3,
created_by_id: 3,
updated_by_id: 3,
approved_by_id: null,
approved_at: null,
sent_by_id: null,
sent_at: null,
created_at: '2026-07-27T08:00:05Z',
updated_at: '2026-07-27T08:03:00Z',
revisions: []
}
const queuedMessage = {
public_id: 'message-15-v3',
endpoint_id: endpoint.id,
endpoint_name: endpoint.name,
conversation_public_id: 'conversation-1',
external_conversation_id: 'group-001',
ticket_id: task.ticket_id,
customer_reply_task_id: task.id,
reply_revision: task.revision,
direction: 'outbound',
external_message_id: '',
idempotency_key: 'customer-reply-15-v3-test',
sender: '支持工程师',
message_type: 'text',
text: task.final_text,
payload: {},
status: 'queued',
error_message: '',
occurred_at: null,
sent_at: null,
delivered_at: null,
created_at: '2026-07-27T08:04:00Z',
updated_at: '2026-07-27T08:04:00Z'
} satisfies ChannelMessage
describe('ChannelReplyDialog', () => {
afterEach(cleanup)
beforeEach(() => {
vi.mocked(listChannelEndpoints).mockResolvedValue([endpoint])
vi.mocked(sendChannelMessage).mockResolvedValue(queuedMessage)
})
it('只提交冻结回复任务与当前版本,不接受任意文本', async () => {
const onQueued = vi.fn().mockResolvedValue(undefined)
const onOpenChange = vi.fn()
render(
<ChannelReplyDialog
task={task}
open
onOpenChange={onOpenChange}
onQueued={onQueued}
/>
)
expect((await screen.findAllByText('客户服务群')).length).toBeGreaterThan(0)
fireEvent.change(screen.getByLabelText('外部会话 ID'), {
target: { value: 'group-001' }
})
const submit = screen.getByRole('button', { name: '冻结版本并发送' })
await waitFor(() => expect(submit).toBeEnabled())
fireEvent.click(submit)
await waitFor(() => {
expect(sendChannelMessage).toHaveBeenCalledWith(
endpoint.id,
expect.objectContaining({
external_conversation_id: 'group-001',
customer_reply_task_id: task.id,
expected_revision: task.revision
})
)
})
const payload = vi.mocked(sendChannelMessage).mock.calls[0][1]
expect(payload).not.toHaveProperty('text')
expect(onQueued).toHaveBeenCalledWith(queuedMessage)
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})

View File

@@ -0,0 +1,198 @@
import * as React from 'react'
import { CircleAlert, Send } from 'lucide-react'
import { toast } from 'sonner'
import { AppField } from '@/components/app/AppField'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import type { CustomerReplyTask } from '@/features/tickets/types'
import { listChannelEndpoints, sendChannelMessage } from './api'
import type { ChannelEndpoint, ChannelMessage } from './types'
function stableHash(value: string) {
let hash = 2166136261
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return (hash >>> 0).toString(16).padStart(8, '0')
}
export function ChannelReplyDialog({
task,
open,
onOpenChange,
onQueued
}: {
task: CustomerReplyTask | null
open: boolean
onOpenChange: (open: boolean) => void
onQueued: (message: ChannelMessage) => Promise<void> | void
}) {
const [endpoints, setEndpoints] = React.useState<ChannelEndpoint[]>([])
const [endpointId, setEndpointId] = React.useState('')
const [conversationId, setConversationId] = React.useState('')
const [loading, setLoading] = React.useState(false)
const [sending, setSending] = React.useState(false)
const [error, setError] = React.useState('')
const selectedEndpointId = endpointId || String(endpoints[0]?.id ?? '')
React.useEffect(() => {
if (!open) return
setLoading(true)
setError('')
listChannelEndpoints()
.then((items) => {
const enabled = items.filter((item) => item.enabled)
setEndpoints(enabled)
setEndpointId((current) => current || String(enabled[0]?.id ?? ''))
})
.catch((reason: unknown) => {
setEndpoints([])
setError(reason instanceof Error ? reason.message : '无法加载可用渠道')
})
.finally(() => setLoading(false))
}, [open])
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!task) return
const normalizedConversation = conversationId.trim()
const numericEndpointId = Number(selectedEndpointId)
if (!numericEndpointId || !normalizedConversation) return
setSending(true)
setError('')
try {
const message = await sendChannelMessage(numericEndpointId, {
external_conversation_id: normalizedConversation,
customer_reply_task_id: task.id,
expected_revision: task.revision,
idempotency_key: [
'customer-reply',
task.id,
`v${task.revision}`,
stableHash(`${numericEndpointId}:${normalizedConversation}`)
].join('-')
})
await onQueued(message)
toast.success('客户回复已进入渠道投递队列')
setConversationId('')
onOpenChange(false)
} catch (reason) {
setError(reason instanceof Error ? reason.message : '客户回复未能进入渠道队列')
} finally {
setSending(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>通过渠道发送客户回复</DialogTitle>
<DialogDescription>
发送后将冻结当前回复版本;只有渠道送达回执会自动确认客户已收到。
</DialogDescription>
</DialogHeader>
{task ? (
<form id="channel-reply-form" onSubmit={handleSubmit}>
<FieldGroup>
<Alert>
<Send />
<AlertTitle>即将发送回复任务 #{task.id}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<Badge variant="outline" className="w-fit">冻结版本 v{task.revision}</Badge>
<span className="line-clamp-4 whitespace-pre-wrap">{task.final_text}</span>
</AlertDescription>
</Alert>
<AppField label="发送渠道" required>
<Select
value={selectedEndpointId}
disabled={loading || sending}
onValueChange={setEndpointId}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={loading ? '正在加载渠道' : '选择已启用渠道'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{endpoints.map((endpoint) => (
<SelectItem key={endpoint.id} value={String(endpoint.id)}>
{endpoint.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</AppField>
<AppField
label="外部会话 ID"
htmlFor="channel-reply-conversation"
required
description="会话首次关联后不能改绑到其他工单。"
>
<Input
id="channel-reply-conversation"
aria-label="外部会话 ID"
required
autoFocus
disabled={sending}
value={conversationId}
onChange={(event) => setConversationId(event.target.value)}
placeholder="例如:group-20260727-001"
/>
</AppField>
{!loading && endpoints.length === 0 ? (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>没有可用渠道</AlertTitle>
<AlertDescription>请先在“渠道接入”中创建并启用适配器。</AlertDescription>
</Alert>
) : null}
{error ? (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>渠道发送未完成</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
</FieldGroup>
</form>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={sending}>
取消
</Button>
<Button
type="submit"
form="channel-reply-form"
disabled={!task || !selectedEndpointId || !conversationId.trim() || sending}
>
<Send data-icon="inline-start" />
{sending ? '正在排队' : '冻结版本并发送'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,68 @@
import { apiRequest } from '@/lib/api'
import type { ChannelEndpoint, ChannelMessage } from './types'
export function listChannelEndpoints() {
return apiRequest<ChannelEndpoint[]>('/channels/endpoints')
}
export function createChannelEndpoint(payload: {
name: string
outbound_url: string
webhook_secret: string
outbound_token: string
enabled: boolean
}) {
return apiRequest<ChannelEndpoint>('/channels/endpoints', {
method: 'POST',
body: JSON.stringify({ ...payload, adapter_type: 'generic_webhook' })
})
}
export function updateChannelEndpoint(
endpointId: number,
payload: Partial<Pick<ChannelEndpoint, 'name' | 'outbound_url' | 'enabled'>> & {
webhook_secret?: string
outbound_token?: string
}
) {
return apiRequest<ChannelEndpoint>(`/channels/endpoints/${endpointId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
export function listChannelMessages(filters?: {
endpoint_id?: number
direction?: string
status?: string
}) {
const search = new URLSearchParams()
if (filters?.endpoint_id) search.set('endpoint_id', String(filters.endpoint_id))
if (filters?.direction) search.set('direction', filters.direction)
if (filters?.status) search.set('status', filters.status)
const suffix = search.size ? `?${search.toString()}` : ''
return apiRequest<ChannelMessage[]>(`/channels/messages${suffix}`)
}
export function sendChannelMessage(
endpointId: number,
payload: {
external_conversation_id: string
idempotency_key: string
customer_reply_task_id: number
expected_revision: number
payload?: Record<string, unknown>
}
) {
return apiRequest<ChannelMessage>(`/channels/endpoints/${endpointId}/messages`, {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function retryChannelMessage(publicId: string) {
return apiRequest<ChannelMessage>(`/channels/messages/${publicId}/retry`, {
method: 'POST'
})
}

View File

@@ -0,0 +1,40 @@
export type ChannelEndpoint = {
id: number
public_id: string
name: string
adapter_type: 'generic_webhook'
outbound_url: string
webhook_secret_set: boolean
outbound_token_set: boolean
enabled: boolean
created_at: string
updated_at: string
}
export type ChannelMessageDirection = 'inbound' | 'outbound'
export type ChannelMessageStatus = 'received' | 'queued' | 'sent' | 'delivered' | 'failed'
export type ChannelMessage = {
public_id: string
endpoint_id: number
endpoint_name: string
conversation_public_id: string | null
external_conversation_id: string
ticket_id: number | null
customer_reply_task_id: number | null
reply_revision: number | null
direction: ChannelMessageDirection
external_message_id: string
idempotency_key: string
sender: string
message_type: string
text: string
payload: Record<string, unknown>
status: ChannelMessageStatus
error_message: string
occurred_at: string | null
sent_at: string | null
delivered_at: string | null
created_at: string
updated_at: string
}

View File

@@ -0,0 +1,396 @@
import * as React from 'react'
import { CircleAlert, LoaderCircle, Save } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { createIdentityProvider, updateIdentityProvider } from './api'
import type { IdentityProvider, IdentityProviderInput } from './types'
type ProviderForm = IdentityProviderInput & {
slug: string
provider_type: 'oidc' | 'ldap'
server_uri: string
base_dn: string
bind_dn: string
user_filter: string
}
const emptyForm: ProviderForm = {
slug: '',
name: '',
provider_type: 'oidc',
enabled: false,
button_label: '',
issuer: '',
metadata_url: '',
client_id: '',
credential: '',
scopes: ['openid', 'profile', 'email'],
claim_mappings: {
subject: 'sub',
username: 'preferred_username',
email: 'email',
display_name: 'name',
groups: 'groups'
},
configuration: {},
auto_create_users: true,
sync_profile: true,
sync_groups: true,
server_uri: '',
base_dn: '',
bind_dn: '',
user_filter: ''
}
type IdentityProviderDialogProps = {
open: boolean
provider: IdentityProvider | null
onOpenChange: (open: boolean) => void
onSaved: () => Promise<void>
}
export function IdentityProviderDialog({
open,
provider,
onOpenChange,
onSaved
}: IdentityProviderDialogProps) {
const [form, setForm] = React.useState<ProviderForm>(emptyForm)
const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState('')
React.useEffect(() => {
if (!open) return
if (!provider) {
setForm(emptyForm)
setError('')
return
}
setForm({
...emptyForm,
slug: provider.slug,
name: provider.name,
provider_type: provider.provider_type,
enabled: provider.enabled,
button_label: provider.button_label,
issuer: provider.issuer,
metadata_url: provider.metadata_url,
client_id: provider.client_id,
scopes: provider.scopes,
claim_mappings: {
...emptyForm.claim_mappings,
...provider.claim_mappings
},
configuration: provider.configuration,
auto_create_users: provider.auto_create_users,
sync_profile: provider.sync_profile,
sync_groups: provider.sync_groups,
server_uri: String(provider.configuration.server_uri ?? ''),
base_dn: String(provider.configuration.base_dn ?? ''),
bind_dn: String(provider.configuration.bind_dn ?? ''),
user_filter: String(provider.configuration.user_filter ?? '')
})
setError('')
}, [open, provider])
function setClaim(key: string, value: string) {
setForm((current) => ({
...current,
claim_mappings: { ...current.claim_mappings, [key]: value }
}))
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setPending(true)
setError('')
const configuration = form.provider_type === 'ldap'
? {
...form.configuration,
server_uri: form.server_uri.trim(),
base_dn: form.base_dn.trim(),
bind_dn: form.bind_dn.trim(),
user_filter: form.user_filter.trim()
}
: form.configuration
const payload: IdentityProviderInput = {
name: form.name.trim(),
enabled: form.enabled,
button_label: form.button_label.trim(),
issuer: form.issuer.trim(),
metadata_url: form.metadata_url.trim(),
client_id: form.client_id.trim(),
credential: form.credential?.trim() || undefined,
scopes: form.scopes,
claim_mappings: form.claim_mappings,
configuration,
auto_create_users: form.auto_create_users,
sync_profile: form.sync_profile,
sync_groups: form.sync_groups
}
try {
if (provider) {
await updateIdentityProvider(provider.id, payload)
} else {
await createIdentityProvider({
...payload,
slug: form.slug.trim(),
provider_type: form.provider_type
})
}
onOpenChange(false)
await onSaved()
} catch (reason) {
setError(reason instanceof Error ? reason.message : '身份提供方保存失败')
} finally {
setPending(false)
}
}
const oidc = form.provider_type === 'oidc'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 sm:max-w-2xl">
<DialogHeader className="border-b bg-muted/35 px-6 py-5 pr-12">
<DialogTitle>{provider ? '编辑身份提供方' : '新增身份提供方'}</DialogTitle>
<DialogDescription>
密钥只在保存时提交,页面与审计日志不会返回明文。
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="scrollbar-panel max-h-[68vh] overflow-y-auto px-6 py-5">
<FieldGroup>
<div className="grid gap-5 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="identity-name">名称</FieldLabel>
<Input
id="identity-name"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
placeholder="例如:公司统一身份"
required
/>
</Field>
<Field data-disabled={Boolean(provider)}>
<FieldLabel htmlFor="identity-slug">稳定标识</FieldLabel>
<Input
id="identity-slug"
value={form.slug}
onChange={(event) => setForm({ ...form, slug: event.target.value })}
placeholder="corp-identity"
disabled={Boolean(provider)}
required
/>
</Field>
</div>
<Field data-disabled={Boolean(provider)}>
<FieldLabel>协议类型</FieldLabel>
<Select
value={form.provider_type}
onValueChange={(value: 'oidc' | 'ldap') => setForm({ ...form, provider_type: value })}
disabled={Boolean(provider)}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="oidc">OpenID Connect</SelectItem>
<SelectItem value="ldap">LDAP 目录适配器</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldDescription>
LDAP 首批提供配置与目录同步适配边界;浏览器单点登录仅由 OIDC 提供。
</FieldDescription>
</Field>
{oidc ? (
<>
<div className="grid gap-5 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="identity-issuer">Issuer</FieldLabel>
<Input
id="identity-issuer"
type="url"
value={form.issuer}
onChange={(event) => setForm({ ...form, issuer: event.target.value })}
placeholder="https://idp.example.com"
/>
</Field>
<Field>
<FieldLabel htmlFor="identity-metadata">发现文档地址</FieldLabel>
<Input
id="identity-metadata"
type="url"
value={form.metadata_url}
onChange={(event) => setForm({ ...form, metadata_url: event.target.value })}
placeholder="留空时由 Issuer 推导"
/>
</Field>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="identity-client-id">Client ID</FieldLabel>
<Input
id="identity-client-id"
value={form.client_id}
onChange={(event) => setForm({ ...form, client_id: event.target.value })}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="identity-credential">
Client secret {provider?.credential_set ? `(${provider.credential_masked})` : ''}
</FieldLabel>
<Input
id="identity-credential"
type="password"
autoComplete="new-password"
value={form.credential}
onChange={(event) => setForm({ ...form, credential: event.target.value })}
placeholder={provider?.credential_set ? '留空保留原密钥' : '输入密钥'}
required={!provider?.credential_set && form.enabled}
/>
</Field>
</div>
<Field>
<FieldLabel htmlFor="identity-button-label">登录按钮文案</FieldLabel>
<Input
id="identity-button-label"
value={form.button_label}
onChange={(event) => setForm({ ...form, button_label: event.target.value })}
placeholder={`使用 ${form.name || '企业账号'} 登录`}
/>
</Field>
<div className="grid gap-5 sm:grid-cols-2">
{[
['subject', 'Subject 声明'],
['username', '账号声明'],
['email', '邮箱声明'],
['display_name', '显示名称声明'],
['groups', '组织声明']
].map(([key, label]) => (
<Field key={key}>
<FieldLabel htmlFor={`identity-claim-${key}`}>{label}</FieldLabel>
<Input
id={`identity-claim-${key}`}
value={form.claim_mappings[key] ?? ''}
onChange={(event) => setClaim(key, event.target.value)}
required
/>
</Field>
))}
</div>
</>
) : (
<>
<Field>
<FieldLabel htmlFor="identity-server-uri">LDAP 服务地址</FieldLabel>
<Input
id="identity-server-uri"
value={form.server_uri}
onChange={(event) => setForm({ ...form, server_uri: event.target.value })}
placeholder="ldaps://directory.example.com"
required={form.enabled}
/>
</Field>
<div className="grid gap-5 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="identity-base-dn">Base DN</FieldLabel>
<Input id="identity-base-dn" value={form.base_dn} onChange={(event) => setForm({ ...form, base_dn: event.target.value })} />
</Field>
<Field>
<FieldLabel htmlFor="identity-bind-dn">Bind DN</FieldLabel>
<Input id="identity-bind-dn" value={form.bind_dn} onChange={(event) => setForm({ ...form, bind_dn: event.target.value })} />
</Field>
</div>
<Field>
<FieldLabel htmlFor="identity-user-filter">用户过滤器</FieldLabel>
<Input id="identity-user-filter" value={form.user_filter} onChange={(event) => setForm({ ...form, user_filter: event.target.value })} placeholder="(objectClass=person)" />
</Field>
<Field>
<FieldLabel htmlFor="identity-bind-password">
Bind password {provider?.credential_set ? `(${provider.credential_masked})` : ''}
</FieldLabel>
<Input
id="identity-bind-password"
type="password"
autoComplete="new-password"
value={form.credential}
onChange={(event) => setForm({ ...form, credential: event.target.value })}
placeholder={provider?.credential_set ? '留空保留原密码' : '支持匿名绑定时可留空'}
/>
</Field>
</>
)}
<Field orientation="horizontal">
<div className="min-w-0 flex-1">
<FieldLabel htmlFor="identity-auto-create">自动创建本地账号</FieldLabel>
<FieldDescription>首次识别到稳定外部身份时创建无本地密码的账号。</FieldDescription>
</div>
<Switch id="identity-auto-create" checked={form.auto_create_users} onCheckedChange={(checked) => setForm({ ...form, auto_create_users: checked })} />
</Field>
<Field orientation="horizontal">
<div className="min-w-0 flex-1">
<FieldLabel htmlFor="identity-sync-profile">同步姓名与邮箱</FieldLabel>
<FieldDescription>企业目录中的最新资料会覆盖对应本地字段。</FieldDescription>
</div>
<Switch id="identity-sync-profile" checked={form.sync_profile} onCheckedChange={(checked) => setForm({ ...form, sync_profile: checked })} />
</Field>
<Field orientation="horizontal">
<div className="min-w-0 flex-1">
<FieldLabel htmlFor="identity-sync-groups">同步组织映射</FieldLabel>
<FieldDescription>只处理本提供方已登记的外部组织映射。</FieldDescription>
</div>
<Switch id="identity-sync-groups" checked={form.sync_groups} onCheckedChange={(checked) => setForm({ ...form, sync_groups: checked })} />
</Field>
<Field orientation="horizontal">
<div className="min-w-0 flex-1">
<FieldLabel htmlFor="identity-enabled">启用提供方</FieldLabel>
<FieldDescription>{oidc ? '启用后会出现在登录页。' : '启用后允许目录适配器使用该配置。'}</FieldDescription>
</div>
<Switch id="identity-enabled" checked={form.enabled} onCheckedChange={(checked) => setForm({ ...form, enabled: checked })} />
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive" className="mt-5">
<CircleAlert />
<AlertTitle>保存未完成</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
</div>
<DialogFooter className="m-0 rounded-none px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>取消</Button>
<Button type="submit" disabled={pending || !form.name.trim() || !form.slug.trim()}>
{pending ? <LoaderCircle data-icon="inline-start" className="animate-spin" /> : <Save data-icon="inline-start" />}
{pending ? '保存中' : '保存提供方'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,107 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SystemIdentityPage } from './SystemIdentityPage'
const mocks = vi.hoisted(() => ({
listIdentityProviders: vi.fn(),
listUserGroups: vi.fn(),
listEnterpriseGroupMappings: vi.fn(),
createEnterpriseGroupMapping: vi.fn(),
deleteEnterpriseGroupMapping: vi.fn(),
syncEnterpriseDirectory: vi.fn(),
updateIdentityProvider: vi.fn(),
createIdentityProvider: vi.fn()
}))
vi.mock('./api', () => ({
...mocks
}))
const provider = {
id: 1,
slug: 'corp',
name: '企业统一身份',
provider_type: 'oidc' as const,
enabled: true,
button_label: '使用企业账号登录',
issuer: 'https://idp.example.com',
metadata_url: '',
client_id: 'ticket-platform',
credential_set: true,
credential_masked: '****cret',
scopes: ['openid', 'profile', 'email'],
claim_mappings: {},
configuration: {},
auto_create_users: true,
sync_profile: true,
sync_groups: true,
identity_count: 12,
group_mapping_count: 1,
created_at: '2026-07-27T00:00:00Z',
updated_at: '2026-07-27T00:00:00Z'
}
describe('SystemIdentityPage', () => {
afterEach(cleanup)
beforeEach(() => {
vi.clearAllMocks()
mocks.listIdentityProviders.mockResolvedValue([provider])
mocks.listUserGroups.mockResolvedValue([
{
id: 10,
name: '一线支持',
code: 'support_l1',
description: '',
is_active: true
}
])
mocks.listEnterpriseGroupMappings.mockResolvedValue([
{
id: 20,
provider_id: 1,
external_group: 'IT-Support',
user_group_id: 10,
user_group_name: '一线支持',
user_group_code: 'support_l1',
created_at: '2026-07-27T00:00:00Z'
}
])
mocks.syncEnterpriseDirectory.mockResolvedValue({
id: 30,
provider_id: 1,
status: 'completed',
total_count: 1,
created_count: 1,
updated_count: 0,
failed_count: 0,
errors: [],
started_at: '2026-07-27T00:00:00Z',
finished_at: '2026-07-27T00:00:01Z'
})
})
it('renders provider status and controlled group mapping', async () => {
render(<SystemIdentityPage />)
expect((await screen.findAllByText('企业统一身份')).length).toBeGreaterThan(0)
expect(await screen.findByText('IT-Support')).toBeInTheDocument()
expect(screen.getByText('12 个身份 · 1 个映射')).toBeInTheDocument()
})
it('submits normalized directory records and reports the run', async () => {
const user = userEvent.setup()
render(<SystemIdentityPage />)
await screen.findByText('IT-Support')
await user.click(screen.getByRole('tab', { name: /目录同步/ }))
await user.click(await screen.findByRole('button', { name: '开始目录同步' }))
await waitFor(() => expect(mocks.syncEnterpriseDirectory).toHaveBeenCalledTimes(1))
expect(mocks.syncEnterpriseDirectory.mock.calls[0][0]).toBe(1)
expect(mocks.syncEnterpriseDirectory.mock.calls[0][1][0].subject).toBe('employee-1001')
expect(await screen.findByText(/同步运行 #30/)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,432 @@
import * as React from 'react'
import { Building2, Link2, Pencil, Plus, RefreshCw, ShieldCheck, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Field, FieldDescription, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { formatDateTime } from '@/lib/format'
import {
createEnterpriseGroupMapping,
deleteEnterpriseGroupMapping,
listEnterpriseGroupMappings,
listIdentityProviders,
listUserGroups,
syncEnterpriseDirectory,
updateIdentityProvider
} from './api'
import { IdentityProviderDialog } from './IdentityProviderDialog'
import type {
DirectorySyncRun,
EnterpriseGroupMapping,
IdentityProvider,
UserGroup
} from './types'
const directorySample = JSON.stringify([
{
subject: 'employee-1001',
username: 'zhangsan',
email: 'zhangsan@example.com',
display_name: '张三',
active: true,
groups: ['IT-Support']
}
], null, 2)
export function SystemIdentityPage() {
const [providers, setProviders] = React.useState<IdentityProvider[]>([])
const [userGroups, setUserGroups] = React.useState<UserGroup[]>([])
const [selectedId, setSelectedId] = React.useState<number | null>(null)
const [mappings, setMappings] = React.useState<EnterpriseGroupMapping[]>([])
const [dialogOpen, setDialogOpen] = React.useState(false)
const [editingProvider, setEditingProvider] = React.useState<IdentityProvider | null>(null)
const [externalGroup, setExternalGroup] = React.useState('')
const [userGroupId, setUserGroupId] = React.useState('')
const [directoryJson, setDirectoryJson] = React.useState(directorySample)
const [syncRun, setSyncRun] = React.useState<DirectorySyncRun | null>(null)
const [mappingToDelete, setMappingToDelete] = React.useState<EnterpriseGroupMapping | null>(null)
const [pending, setPending] = React.useState<string | null>(null)
const [error, setError] = React.useState('')
const selected = providers.find((provider) => provider.id === selectedId) ?? null
const loadProviders = React.useCallback(async () => {
const next = await listIdentityProviders()
setProviders(next)
setSelectedId((current) => current && next.some((item) => item.id === current) ? current : next[0]?.id ?? null)
}, [])
React.useEffect(() => {
Promise.all([loadProviders(), listUserGroups().then(setUserGroups)])
.then(() => setError(''))
.catch((reason) => setError(reason instanceof Error ? reason.message : '身份治理配置加载失败'))
}, [loadProviders])
React.useEffect(() => {
if (!selectedId) {
setMappings([])
return
}
void listEnterpriseGroupMappings(selectedId)
.then(setMappings)
.catch((reason) => setError(reason instanceof Error ? reason.message : '组织映射加载失败'))
}, [selectedId])
async function toggleProvider(provider: IdentityProvider, enabled: boolean) {
setPending(`provider-${provider.id}`)
try {
await updateIdentityProvider(provider.id, { enabled })
await loadProviders()
toast.success(enabled ? '身份提供方已启用' : '身份提供方已停用')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '身份提供方状态更新失败')
} finally {
setPending(null)
}
}
async function addMapping(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!selected || !userGroupId) return
setPending('mapping')
try {
await createEnterpriseGroupMapping(selected.id, externalGroup.trim(), Number(userGroupId))
setExternalGroup('')
setUserGroupId('')
setMappings(await listEnterpriseGroupMappings(selected.id))
await loadProviders()
toast.success('组织映射已创建')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '组织映射创建失败')
} finally {
setPending(null)
}
}
async function removeMapping(mapping: EnterpriseGroupMapping) {
setPending(`mapping-${mapping.id}`)
try {
await deleteEnterpriseGroupMapping(mapping.id)
setMappings(await listEnterpriseGroupMappings(mapping.provider_id))
await loadProviders()
setMappingToDelete(null)
toast.success('组织映射已删除')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '组织映射删除失败')
} finally {
setPending(null)
}
}
async function runSync(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!selected) return
setPending('sync')
setSyncRun(null)
try {
const parsed: unknown = JSON.parse(directoryJson)
if (!Array.isArray(parsed)) throw new Error('目录数据必须是 JSON 数组')
const run = await syncEnterpriseDirectory(selected.id, parsed)
setSyncRun(run)
await loadProviders()
toast.success(run.failed_count ? '目录同步已完成,部分记录需处理' : '目录同步已完成')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '目录同步失败')
} finally {
setPending(null)
}
}
const providerColumns: AppDataTableColumn<IdentityProvider>[] = [
{
key: 'provider',
header: '身份提供方',
cell: (provider) => (
<div>
<p className="font-medium">{provider.name}</p>
<p className="mt-1 text-sm text-muted-foreground">{provider.slug}</p>
</div>
)
},
{
key: 'type',
header: '协议',
cell: (provider) => <Badge variant="outline">{provider.provider_type === 'oidc' ? 'OIDC' : 'LDAP'}</Badge>
},
{
key: 'counts',
header: '同步范围',
cell: (provider) => (
<span className="text-sm text-muted-foreground">
{provider.identity_count} 个身份 · {provider.group_mapping_count} 个映射
</span>
)
},
{
key: 'status',
header: '状态',
cell: (provider) => (
<div className="flex items-center gap-2">
<Switch
checked={provider.enabled}
disabled={pending === `provider-${provider.id}`}
onCheckedChange={(checked) => void toggleProvider(provider, checked)}
aria-label={`${provider.name}启用状态`}
/>
<Badge variant={provider.enabled ? 'success' : 'secondary'}>{provider.enabled ? '已启用' : '未启用'}</Badge>
</div>
)
},
{
key: 'actions',
header: '',
className: 'text-right',
cell: (provider) => (
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingProvider(provider)
setDialogOpen(true)
}}
>
<Pencil data-icon="inline-start" />
编辑
</Button>
)
}
]
const mappingColumns: AppDataTableColumn<EnterpriseGroupMapping>[] = [
{
key: 'external',
header: '企业组织标识',
cell: (mapping) => <span className="font-mono text-sm">{mapping.external_group}</span>
},
{
key: 'local',
header: '平台用户组',
cell: (mapping) => (
<div>
<p className="font-medium">{mapping.user_group_name}</p>
<p className="mt-1 text-sm text-muted-foreground">{mapping.user_group_code}</p>
</div>
)
},
{
key: 'created',
header: '创建时间',
cell: (mapping) => <span className="text-muted-foreground">{formatDateTime(mapping.created_at)}</span>
},
{
key: 'actions',
header: '',
className: 'text-right',
cell: (mapping) => (
<Button
variant="ghost"
size="icon-sm"
aria-label={`删除 ${mapping.external_group} 映射`}
disabled={pending === `mapping-${mapping.id}`}
onClick={() => setMappingToDelete(mapping)}
>
<Trash2 />
</Button>
)
}
]
return (
<AppPage
header={(
<AppPageHeader
title="企业身份"
description="统一管理单点登录、外部身份绑定与企业组织映射。"
actions={(
<Button onClick={() => { setEditingProvider(null); setDialogOpen(true) }}>
<Plus data-icon="inline-start" />
新增提供方
</Button>
)}
/>
)}
>
{error ? (
<Alert variant="destructive">
<AlertTitle>操作未完成</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<AppDataTable
columns={providerColumns}
rows={providers}
getRowKey={(provider) => provider.id}
emptyTitle="尚未配置企业身份"
emptyDescription="新增 OIDC 提供方后,启用的企业登录入口会出现在登录页。"
footer={<p className="text-sm text-muted-foreground">共 {providers.length} 个身份提供方</p>}
/>
{selected ? (
<Card>
<CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2"><Building2 />组织同步工作区</CardTitle>
<CardDescription className="mt-1">选择提供方,维护受控映射或通过目录适配器导入成员。</CardDescription>
</div>
<Select value={String(selected.id)} onValueChange={(value) => setSelectedId(Number(value))}>
<SelectTrigger className="w-full sm:w-64"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
{providers.map((provider) => <SelectItem key={provider.id} value={String(provider.id)}>{provider.name}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
<Tabs defaultValue="mappings">
<TabsList>
<TabsTrigger value="mappings"><Link2 />组织映射</TabsTrigger>
<TabsTrigger value="sync"><RefreshCw />目录同步</TabsTrigger>
</TabsList>
<TabsContent value="mappings" className="mt-4 flex flex-col gap-4">
<form onSubmit={addMapping}>
<FieldGroup className="grid items-end gap-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
<Field>
<FieldLabel htmlFor="external-group">企业组织标识</FieldLabel>
<Input id="external-group" value={externalGroup} onChange={(event) => setExternalGroup(event.target.value)} placeholder="例如:IT-Support" required />
</Field>
<Field>
<FieldLabel>平台用户组</FieldLabel>
<Select value={userGroupId} onValueChange={setUserGroupId}>
<SelectTrigger className="w-full"><SelectValue placeholder="选择用户组" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{userGroups.filter((group) => group.is_active).map((group) => <SelectItem key={group.id} value={String(group.id)}>{group.name}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Button type="submit" disabled={pending === 'mapping' || !externalGroup.trim() || !userGroupId}>
<Plus data-icon="inline-start" />
创建映射
</Button>
</FieldGroup>
</form>
<AppDataTable
columns={mappingColumns}
rows={mappings}
getRowKey={(mapping) => mapping.id}
emptyTitle="暂无组织映射"
emptyDescription="没有映射的企业组织不会改变平台中的用户组归属。"
/>
</TabsContent>
<TabsContent value="sync" className="mt-4">
<form className="flex flex-col gap-4" onSubmit={runSync}>
<Alert>
<ShieldCheck />
<AlertTitle>目录适配器输入</AlertTitle>
<AlertDescription>
该入口接收已由可信连接器读取并标准化的成员记录;不会在浏览器中保存 LDAP 密码。
</AlertDescription>
</Alert>
<Field>
<FieldLabel htmlFor="directory-records">标准化成员 JSON</FieldLabel>
<Textarea
id="directory-records"
className="min-h-64 font-mono text-xs"
value={directoryJson}
onChange={(event) => setDirectoryJson(event.target.value)}
/>
<FieldDescription>单次最多 1000 条;同一 subject 重复同步只会更新原身份。</FieldDescription>
</Field>
<Button type="submit" className="self-end" disabled={pending === 'sync'}>
<RefreshCw data-icon="inline-start" />
{pending === 'sync' ? '同步中' : '开始目录同步'}
</Button>
{syncRun ? (
<Alert variant={syncRun.failed_count ? 'destructive' : 'default'}>
<AlertTitle>同步运行 #{syncRun.id} · {syncRun.status}</AlertTitle>
<AlertDescription>
共 {syncRun.total_count} 条,新建 {syncRun.created_count},更新 {syncRun.updated_count},失败 {syncRun.failed_count}。
</AlertDescription>
</Alert>
) : null}
</form>
</TabsContent>
</Tabs>
</CardContent>
</Card>
) : providers.length === 0 ? (
<AppEmptyState title="等待身份提供方" description="配置提供方后即可维护组织映射与目录同步。" />
) : null}
<IdentityProviderDialog
open={dialogOpen}
provider={editingProvider}
onOpenChange={setDialogOpen}
onSaved={loadProviders}
/>
<AlertDialog
open={Boolean(mappingToDelete)}
onOpenChange={(open) => {
if (!open && !pending?.startsWith('mapping-')) setMappingToDelete(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除组织映射?</AlertDialogTitle>
<AlertDialogDescription>
删除后,企业组织“{mappingToDelete?.external_group}”将不再同步到平台用户组。已有用户不会在本次操作中被修改。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={Boolean(pending?.startsWith('mapping-'))}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={!mappingToDelete || Boolean(pending?.startsWith('mapping-'))}
onClick={(event) => {
event.preventDefault()
if (mappingToDelete) void removeMapping(mappingToDelete)
}}
>
{pending?.startsWith('mapping-') ? '删除中' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AppPage>
)
}

View File

@@ -1,6 +1,17 @@
import { apiRequest } from '@/lib/api' import { apiRequest } from '@/lib/api'
import type { AuditLog, Permission, RoleDetail, SystemMenu, SystemUser, UserGroup } from './types' import type {
AuditLog,
DirectorySyncRun,
EnterpriseGroupMapping,
IdentityProvider,
IdentityProviderInput,
Permission,
RoleDetail,
SystemMenu,
SystemUser,
UserGroup
} from './types'
export function listUsers() { export function listUsers() {
return apiRequest<SystemUser[]>('/accounts/users') return apiRequest<SystemUser[]>('/accounts/users')
@@ -138,3 +149,55 @@ export function updateMenu(
export function listAuditLogs() { export function listAuditLogs() {
return apiRequest<AuditLog[]>('/audit/operation-logs') return apiRequest<AuditLog[]>('/audit/operation-logs')
} }
export function listIdentityProviders() {
return apiRequest<IdentityProvider[]>('/identity/providers')
}
export function createIdentityProvider(payload: IdentityProviderInput & { slug: string; provider_type: 'oidc' | 'ldap' }) {
return apiRequest<IdentityProvider>('/identity/providers', {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function updateIdentityProvider(providerId: number, payload: Partial<IdentityProviderInput>) {
return apiRequest<IdentityProvider>(`/identity/providers/${providerId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
export function listEnterpriseGroupMappings(providerId: number) {
return apiRequest<EnterpriseGroupMapping[]>(`/identity/providers/${providerId}/group-mappings`)
}
export function createEnterpriseGroupMapping(providerId: number, externalGroup: string, userGroupId: number) {
return apiRequest<EnterpriseGroupMapping>(`/identity/providers/${providerId}/group-mappings`, {
method: 'POST',
body: JSON.stringify({ external_group: externalGroup, user_group_id: userGroupId })
})
}
export function deleteEnterpriseGroupMapping(mappingId: number) {
return apiRequest<{ status: string }>(`/identity/group-mappings/${mappingId}`, {
method: 'DELETE'
})
}
export function syncEnterpriseDirectory(
providerId: number,
records: Array<{
subject: string
username?: string
email?: string
display_name?: string
active?: boolean
groups?: string[]
}>
) {
return apiRequest<DirectorySyncRun>(`/identity/providers/${providerId}/directory-sync`, {
method: 'POST',
body: JSON.stringify({ records })
})
}

View File

@@ -61,3 +61,68 @@ export type AuditLog = {
user_agent: string user_agent: string
created_at: string created_at: string
} }
export type IdentityProvider = {
id: number
slug: string
name: string
provider_type: 'oidc' | 'ldap'
enabled: boolean
button_label: string
issuer: string
metadata_url: string
client_id: string
credential_set: boolean
credential_masked: string
scopes: string[]
claim_mappings: Record<string, string>
configuration: Record<string, unknown>
auto_create_users: boolean
sync_profile: boolean
sync_groups: boolean
identity_count: number
group_mapping_count: number
created_at: string
updated_at: string
}
export type IdentityProviderInput = {
slug?: string
name: string
provider_type?: 'oidc' | 'ldap'
enabled: boolean
button_label: string
issuer: string
metadata_url: string
client_id: string
credential?: string
scopes: string[]
claim_mappings: Record<string, string>
configuration: Record<string, unknown>
auto_create_users: boolean
sync_profile: boolean
sync_groups: boolean
}
export type EnterpriseGroupMapping = {
id: number
provider_id: number
external_group: string
user_group_id: number
user_group_name: string
user_group_code: string
created_at: string
}
export type DirectorySyncRun = {
id: number
provider_id: number
status: 'completed' | 'partial' | 'failed'
total_count: number
created_count: number
updated_count: number
failed_count: number
errors: Array<{ index: string; subject: string; error: string }>
started_at: string
finished_at: string | null
}

View File

@@ -1,5 +1,9 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api' const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api'
export function apiUrl(path: string) {
return `${API_BASE_URL}${path}`
}
let csrfToken: string | null = null let csrfToken: string | null = null
export class ApiError extends Error { export class ApiError extends Error {
@@ -29,7 +33,7 @@ async function readError(response: Response) {
async function getCsrfToken() { async function getCsrfToken() {
if (!csrfToken) { if (!csrfToken) {
const response = await fetch(`${API_BASE_URL}/accounts/auth/csrf`, { const response = await fetch(apiUrl('/accounts/auth/csrf'), {
credentials: 'include' credentials: 'include'
}) })
if (!response.ok) { if (!response.ok) {
@@ -51,7 +55,7 @@ export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T
headers.set('x-csrftoken', await getCsrfToken()) headers.set('x-csrftoken', await getCsrfToken())
} }
const response = await fetch(`${API_BASE_URL}${path}`, { const response = await fetch(apiUrl(path), {
...init, ...init,
credentials: 'include', credentials: 'include',
headers headers