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

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

View File

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

View File

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