feat: add enterprise identity and channel integrations
This commit is contained in:
381
backend/apps/accounts/identity_api.py
Normal file
381
backend/apps/accounts/identity_api.py
Normal file
@@ -0,0 +1,381 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import login
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Count
|
||||
from django.http import HttpResponseRedirect
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from apps.accounts.api import require_permission
|
||||
from apps.accounts.identity_schemas import (
|
||||
DirectorySyncIn,
|
||||
DirectorySyncRunOut,
|
||||
EnterpriseGroupMappingIn,
|
||||
EnterpriseGroupMappingOut,
|
||||
IdentityProviderCreateIn,
|
||||
IdentityProviderOut,
|
||||
IdentityProviderUpdateIn,
|
||||
PublicIdentityProviderOut,
|
||||
)
|
||||
from apps.accounts.identity_services import (
|
||||
IdentitySyncError,
|
||||
run_directory_sync,
|
||||
sync_identity_claims,
|
||||
)
|
||||
from apps.accounts.models import (
|
||||
EnterpriseGroupMapping,
|
||||
EnterpriseIdentityProvider,
|
||||
UserGroup,
|
||||
)
|
||||
from apps.accounts.oidc import (
|
||||
OIDCConfigurationError,
|
||||
build_oidc_client,
|
||||
callback_uri,
|
||||
frontend_redirect,
|
||||
normalize_return_to,
|
||||
)
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.modelhub.crypto import (
|
||||
CredentialEncryptionError,
|
||||
encrypt_secret,
|
||||
mask_secret,
|
||||
)
|
||||
|
||||
router = Router(tags=["enterprise-identity"])
|
||||
|
||||
SENSITIVE_CONFIG_FRAGMENTS = ("password", "secret", "token", "credential")
|
||||
|
||||
|
||||
def _audit(user, action: str, target_type: str, target_id: str, snapshot: dict, result="success"):
|
||||
OperationAuditLog.objects.create(
|
||||
user=user if getattr(user, "is_authenticated", False) else None,
|
||||
module="identity",
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
request_snapshot=snapshot,
|
||||
result=result,
|
||||
)
|
||||
|
||||
|
||||
def _validate_configuration(configuration: Mapping[str, Any]) -> None:
|
||||
def visit(value: Any) -> None:
|
||||
if isinstance(value, Mapping):
|
||||
for key, nested in value.items():
|
||||
lowered = str(key).lower()
|
||||
if any(fragment in lowered for fragment in SENSITIVE_CONFIG_FRAGMENTS):
|
||||
raise HttpError(400, f"敏感配置 {key} 必须通过 credential 字段加密保存")
|
||||
visit(nested)
|
||||
elif isinstance(value, list):
|
||||
for nested in value:
|
||||
visit(nested)
|
||||
|
||||
visit(configuration)
|
||||
|
||||
|
||||
def _validate_provider(provider: EnterpriseIdentityProvider) -> None:
|
||||
if provider.provider_type == EnterpriseIdentityProvider.ProviderType.OIDC:
|
||||
if not provider.metadata_url and not provider.issuer:
|
||||
raise HttpError(400, "OIDC 提供方必须配置 metadata_url 或 issuer")
|
||||
if not provider.client_id:
|
||||
raise HttpError(400, "OIDC 提供方必须配置 client_id")
|
||||
if "openid" not in (provider.scopes or []):
|
||||
raise HttpError(400, "OIDC scopes 必须包含 openid")
|
||||
if provider.enabled and not provider.credential_encrypted:
|
||||
raise HttpError(400, "启用 OIDC 前必须配置 client secret")
|
||||
for url in (provider.issuer, provider.metadata_url):
|
||||
if not url:
|
||||
continue
|
||||
parsed = urlparse(url)
|
||||
local_development = settings.DEBUG and parsed.hostname in {"localhost", "127.0.0.1"}
|
||||
if parsed.scheme != "https" and not local_development:
|
||||
raise HttpError(400, "OIDC 地址必须使用 HTTPS")
|
||||
elif provider.provider_type == EnterpriseIdentityProvider.ProviderType.LDAP:
|
||||
if provider.enabled and not provider.configuration.get("server_uri"):
|
||||
raise HttpError(400, "启用 LDAP 前必须配置 server_uri")
|
||||
else:
|
||||
raise HttpError(400, "不支持的身份提供方类型")
|
||||
|
||||
|
||||
def _serialize_provider(provider: EnterpriseIdentityProvider) -> dict:
|
||||
return {
|
||||
"id": provider.id,
|
||||
"slug": provider.slug,
|
||||
"name": provider.name,
|
||||
"provider_type": provider.provider_type,
|
||||
"enabled": provider.enabled,
|
||||
"button_label": provider.button_label or f"使用 {provider.name} 登录",
|
||||
"issuer": provider.issuer,
|
||||
"metadata_url": provider.metadata_url,
|
||||
"client_id": provider.client_id,
|
||||
"credential_set": bool(provider.credential_encrypted),
|
||||
"credential_masked": mask_secret(provider.credential_encrypted),
|
||||
"scopes": provider.scopes,
|
||||
"claim_mappings": provider.claim_mappings,
|
||||
"configuration": provider.configuration,
|
||||
"auto_create_users": provider.auto_create_users,
|
||||
"sync_profile": provider.sync_profile,
|
||||
"sync_groups": provider.sync_groups,
|
||||
"identity_count": getattr(provider, "identity_count", 0),
|
||||
"group_mapping_count": getattr(provider, "group_mapping_count", 0),
|
||||
"created_at": provider.created_at,
|
||||
"updated_at": provider.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_mapping(mapping: EnterpriseGroupMapping) -> dict:
|
||||
return {
|
||||
"id": mapping.id,
|
||||
"provider_id": mapping.provider_id,
|
||||
"external_group": mapping.external_group,
|
||||
"user_group_id": mapping.user_group_id,
|
||||
"user_group_name": mapping.user_group.name,
|
||||
"user_group_code": mapping.user_group.code,
|
||||
"created_at": mapping.created_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/auth/providers", response=list[PublicIdentityProviderOut], auth=None)
|
||||
def public_providers(request):
|
||||
return [
|
||||
{
|
||||
"slug": provider.slug,
|
||||
"name": provider.name,
|
||||
"button_label": provider.button_label or f"使用 {provider.name} 登录",
|
||||
"provider_type": provider.provider_type,
|
||||
}
|
||||
for provider in EnterpriseIdentityProvider.objects.filter(
|
||||
enabled=True,
|
||||
provider_type=EnterpriseIdentityProvider.ProviderType.OIDC,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/auth/oidc/{slug}/start", auth=None)
|
||||
def oidc_start(request, slug: str, return_to: str = "/"):
|
||||
provider = EnterpriseIdentityProvider.objects.filter(
|
||||
slug=slug,
|
||||
enabled=True,
|
||||
provider_type=EnterpriseIdentityProvider.ProviderType.OIDC,
|
||||
).first()
|
||||
if provider is None:
|
||||
raise HttpError(404, "身份提供方不存在或未启用")
|
||||
try:
|
||||
client = build_oidc_client(provider)
|
||||
request.session[f"identity_return_to:{provider.slug}"] = normalize_return_to(return_to)
|
||||
return client.authorize_redirect(request, callback_uri(request, provider))
|
||||
except (OIDCConfigurationError, CredentialEncryptionError) as error:
|
||||
_audit(None, "oidc_start_failed", "identity_provider", str(provider.id), {}, "failed")
|
||||
raise HttpError(503, str(error)) from error
|
||||
|
||||
|
||||
@router.get("/auth/oidc/{slug}/callback", auth=None)
|
||||
def oidc_callback(request, slug: str):
|
||||
provider = EnterpriseIdentityProvider.objects.filter(
|
||||
slug=slug,
|
||||
enabled=True,
|
||||
provider_type=EnterpriseIdentityProvider.ProviderType.OIDC,
|
||||
).first()
|
||||
if provider is None:
|
||||
return HttpResponseRedirect(frontend_redirect(error="身份提供方不存在或未启用"))
|
||||
try:
|
||||
client = build_oidc_client(provider)
|
||||
token = client.authorize_access_token(request)
|
||||
claims = token.get("userinfo")
|
||||
if not isinstance(claims, Mapping):
|
||||
raise IdentitySyncError("OIDC 响应缺少已校验的用户身份声明")
|
||||
identity, created = sync_identity_claims(provider, claims, login_event=True)
|
||||
login(request, identity.user)
|
||||
return_to = request.session.pop(f"identity_return_to:{provider.slug}", "/")
|
||||
_audit(
|
||||
identity.user,
|
||||
"oidc_login",
|
||||
"external_identity",
|
||||
str(identity.id),
|
||||
{"provider": provider.slug, "identity_created": created},
|
||||
)
|
||||
return HttpResponseRedirect(frontend_redirect(return_to))
|
||||
except Exception as error:
|
||||
_audit(
|
||||
None,
|
||||
"oidc_callback_failed",
|
||||
"identity_provider",
|
||||
str(provider.id),
|
||||
{"error_type": type(error).__name__},
|
||||
"failed",
|
||||
)
|
||||
return HttpResponseRedirect(frontend_redirect(error="企业登录失败,请联系管理员"))
|
||||
|
||||
|
||||
@router.get("/providers", response=list[IdentityProviderOut])
|
||||
def list_providers(request):
|
||||
require_permission(request, "system.setting.manage")
|
||||
queryset = EnterpriseIdentityProvider.objects.annotate(
|
||||
identity_count=Count("external_identities", distinct=True),
|
||||
group_mapping_count=Count("group_mappings", distinct=True),
|
||||
)
|
||||
return [_serialize_provider(provider) for provider in queryset]
|
||||
|
||||
|
||||
@router.post("/providers", response=IdentityProviderOut)
|
||||
def create_provider(request, payload: IdentityProviderCreateIn):
|
||||
require_permission(request, "system.setting.manage")
|
||||
values = payload.dict()
|
||||
credential = values.pop("credential", "")
|
||||
_validate_configuration(values["configuration"])
|
||||
try:
|
||||
with transaction.atomic():
|
||||
provider = EnterpriseIdentityProvider(
|
||||
**values,
|
||||
credential_encrypted=encrypt_secret(credential) if credential else "",
|
||||
created_by=request.user,
|
||||
updated_by=request.user,
|
||||
)
|
||||
_validate_provider(provider)
|
||||
provider.full_clean()
|
||||
provider.save()
|
||||
except CredentialEncryptionError as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
except ValidationError as error:
|
||||
raise HttpError(400, "; ".join(error.messages)) from error
|
||||
except IntegrityError as error:
|
||||
raise HttpError(400, "身份提供方标识已存在") from error
|
||||
_audit(
|
||||
request.user,
|
||||
"create_provider",
|
||||
"identity_provider",
|
||||
str(provider.id),
|
||||
{"slug": provider.slug, "provider_type": provider.provider_type},
|
||||
)
|
||||
provider.identity_count = 0
|
||||
provider.group_mapping_count = 0
|
||||
return _serialize_provider(provider)
|
||||
|
||||
|
||||
@router.patch("/providers/{provider_id}", response=IdentityProviderOut)
|
||||
def update_provider(request, provider_id: int, payload: IdentityProviderUpdateIn):
|
||||
require_permission(request, "system.setting.manage")
|
||||
provider = EnterpriseIdentityProvider.objects.get(id=provider_id)
|
||||
values = payload.dict(exclude_unset=True)
|
||||
credential = values.pop("credential", None)
|
||||
if "configuration" in values:
|
||||
_validate_configuration(values["configuration"])
|
||||
try:
|
||||
if credential:
|
||||
provider.credential_encrypted = encrypt_secret(credential)
|
||||
for field, value in values.items():
|
||||
setattr(provider, field, value)
|
||||
provider.updated_by = request.user
|
||||
_validate_provider(provider)
|
||||
provider.full_clean()
|
||||
provider.save()
|
||||
except CredentialEncryptionError as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
except ValidationError as error:
|
||||
raise HttpError(400, "; ".join(error.messages)) from error
|
||||
_audit(
|
||||
request.user,
|
||||
"update_provider",
|
||||
"identity_provider",
|
||||
str(provider.id),
|
||||
{"fields": sorted(values), "credential_replaced": bool(credential)},
|
||||
)
|
||||
provider.identity_count = provider.external_identities.count()
|
||||
provider.group_mapping_count = provider.group_mappings.count()
|
||||
return _serialize_provider(provider)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/providers/{provider_id}/group-mappings",
|
||||
response=list[EnterpriseGroupMappingOut],
|
||||
)
|
||||
def list_group_mappings(request, provider_id: int):
|
||||
require_permission(request, "system.setting.manage")
|
||||
mappings = EnterpriseGroupMapping.objects.select_related("user_group").filter(
|
||||
provider_id=provider_id
|
||||
)
|
||||
return [_serialize_mapping(mapping) for mapping in mappings]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/providers/{provider_id}/group-mappings",
|
||||
response=EnterpriseGroupMappingOut,
|
||||
)
|
||||
def create_group_mapping(
|
||||
request,
|
||||
provider_id: int,
|
||||
payload: EnterpriseGroupMappingIn,
|
||||
):
|
||||
require_permission(request, "system.setting.manage")
|
||||
provider = EnterpriseIdentityProvider.objects.get(id=provider_id)
|
||||
user_group = UserGroup.objects.get(id=payload.user_group_id)
|
||||
external_group = payload.external_group.strip()
|
||||
if not external_group:
|
||||
raise HttpError(400, "企业组织标识不能为空")
|
||||
try:
|
||||
mapping = EnterpriseGroupMapping.objects.create(
|
||||
provider=provider,
|
||||
external_group=external_group,
|
||||
user_group=user_group,
|
||||
)
|
||||
except IntegrityError as error:
|
||||
raise HttpError(400, "该外部组织映射已存在") from error
|
||||
_audit(
|
||||
request.user,
|
||||
"create_group_mapping",
|
||||
"identity_provider",
|
||||
str(provider.id),
|
||||
{"external_group": mapping.external_group, "user_group_id": user_group.id},
|
||||
)
|
||||
return _serialize_mapping(mapping)
|
||||
|
||||
|
||||
@router.delete("/group-mappings/{mapping_id}")
|
||||
def delete_group_mapping(request, mapping_id: int):
|
||||
require_permission(request, "system.setting.manage")
|
||||
mapping = EnterpriseGroupMapping.objects.select_related("provider").get(id=mapping_id)
|
||||
snapshot = {
|
||||
"external_group": mapping.external_group,
|
||||
"user_group_id": mapping.user_group_id,
|
||||
}
|
||||
provider_id = mapping.provider_id
|
||||
mapping.delete()
|
||||
_audit(
|
||||
request.user,
|
||||
"delete_group_mapping",
|
||||
"identity_provider",
|
||||
str(provider_id),
|
||||
snapshot,
|
||||
)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/providers/{provider_id}/directory-sync",
|
||||
response=DirectorySyncRunOut,
|
||||
)
|
||||
def directory_sync(request, provider_id: int, payload: DirectorySyncIn):
|
||||
require_permission(request, "system.setting.manage")
|
||||
if len(payload.records) > 1000:
|
||||
raise HttpError(400, "单次目录同步最多接收 1000 条记录")
|
||||
provider = EnterpriseIdentityProvider.objects.get(id=provider_id)
|
||||
records = [record.dict() for record in payload.records]
|
||||
run = run_directory_sync(provider, records, request.user)
|
||||
_audit(
|
||||
request.user,
|
||||
"directory_sync",
|
||||
"identity_provider",
|
||||
str(provider.id),
|
||||
{
|
||||
"run_id": run.id,
|
||||
"total_count": run.total_count,
|
||||
"failed_count": run.failed_count,
|
||||
},
|
||||
"success" if run.failed_count == 0 else "failed",
|
||||
)
|
||||
return run
|
||||
110
backend/apps/accounts/identity_schemas.py
Normal file
110
backend/apps/accounts/identity_schemas.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class PublicIdentityProviderOut(Schema):
|
||||
slug: str
|
||||
name: str
|
||||
button_label: str
|
||||
provider_type: str
|
||||
|
||||
|
||||
class IdentityProviderOut(Schema):
|
||||
id: int
|
||||
slug: str
|
||||
name: str
|
||||
provider_type: str
|
||||
enabled: bool
|
||||
button_label: str
|
||||
issuer: str
|
||||
metadata_url: str
|
||||
client_id: str
|
||||
credential_set: bool
|
||||
credential_masked: str
|
||||
scopes: list[str]
|
||||
claim_mappings: dict[str, str]
|
||||
configuration: dict[str, Any]
|
||||
auto_create_users: bool
|
||||
sync_profile: bool
|
||||
sync_groups: bool
|
||||
identity_count: int
|
||||
group_mapping_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class IdentityProviderCreateIn(Schema):
|
||||
slug: str
|
||||
name: str
|
||||
provider_type: str = "oidc"
|
||||
enabled: bool = False
|
||||
button_label: str = ""
|
||||
issuer: str = ""
|
||||
metadata_url: str = ""
|
||||
client_id: str = ""
|
||||
credential: str = ""
|
||||
scopes: list[str] = ["openid", "profile", "email"]
|
||||
claim_mappings: dict[str, str] = {}
|
||||
configuration: dict[str, Any] = {}
|
||||
auto_create_users: bool = True
|
||||
sync_profile: bool = True
|
||||
sync_groups: bool = True
|
||||
|
||||
|
||||
class IdentityProviderUpdateIn(Schema):
|
||||
name: str | None = None
|
||||
enabled: bool | None = None
|
||||
button_label: str | None = None
|
||||
issuer: str | None = None
|
||||
metadata_url: str | None = None
|
||||
client_id: str | None = None
|
||||
credential: str | None = None
|
||||
scopes: list[str] | None = None
|
||||
claim_mappings: dict[str, str] | None = None
|
||||
configuration: dict[str, Any] | None = None
|
||||
auto_create_users: bool | None = None
|
||||
sync_profile: bool | None = None
|
||||
sync_groups: bool | None = None
|
||||
|
||||
|
||||
class EnterpriseGroupMappingOut(Schema):
|
||||
id: int
|
||||
provider_id: int
|
||||
external_group: str
|
||||
user_group_id: int
|
||||
user_group_name: str
|
||||
user_group_code: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EnterpriseGroupMappingIn(Schema):
|
||||
external_group: str
|
||||
user_group_id: int
|
||||
|
||||
|
||||
class DirectoryRecordIn(Schema):
|
||||
subject: str
|
||||
username: str = ""
|
||||
email: str = ""
|
||||
display_name: str = ""
|
||||
active: bool = True
|
||||
groups: list[str] = []
|
||||
|
||||
|
||||
class DirectorySyncIn(Schema):
|
||||
records: list[DirectoryRecordIn]
|
||||
|
||||
|
||||
class DirectorySyncRunOut(Schema):
|
||||
id: int
|
||||
provider_id: int
|
||||
status: str
|
||||
total_count: int
|
||||
created_count: int
|
||||
updated_count: int
|
||||
failed_count: int
|
||||
errors: list[dict[str, Any]]
|
||||
started_at: datetime
|
||||
finished_at: datetime | None = None
|
||||
230
backend/apps/accounts/identity_services.py
Normal file
230
backend/apps/accounts/identity_services.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""企业身份声明、目录账号和组织映射同步服务。"""
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import (
|
||||
EnterpriseDirectorySyncRun,
|
||||
EnterpriseIdentityProvider,
|
||||
ExternalIdentity,
|
||||
)
|
||||
|
||||
DEFAULT_CLAIM_MAPPINGS = {
|
||||
"subject": "sub",
|
||||
"username": "preferred_username",
|
||||
"email": "email",
|
||||
"display_name": "name",
|
||||
"groups": "groups",
|
||||
}
|
||||
|
||||
|
||||
class IdentitySyncError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def resolved_claim_mappings(provider: EnterpriseIdentityProvider) -> dict[str, str]:
|
||||
return {**DEFAULT_CLAIM_MAPPINGS, **(provider.claim_mappings or {})}
|
||||
|
||||
|
||||
def read_claim(claims: Mapping[str, Any], path: str, default: Any = "") -> Any:
|
||||
value: Any = claims
|
||||
for part in path.split("."):
|
||||
if not isinstance(value, Mapping) or part not in value:
|
||||
return default
|
||||
value = value[part]
|
||||
return value
|
||||
|
||||
|
||||
def _clean_username(value: str) -> str:
|
||||
cleaned = re.sub(r"[^@\w.+-]", "_", value.strip(), flags=re.UNICODE).strip("._")
|
||||
return cleaned[:150]
|
||||
|
||||
|
||||
def _unique_username(provider: EnterpriseIdentityProvider, preferred: str, subject: str) -> str:
|
||||
user_model = get_user_model()
|
||||
base = (
|
||||
_clean_username(preferred)
|
||||
or _clean_username(f"{provider.slug}_{subject}")
|
||||
or provider.slug
|
||||
)
|
||||
if not user_model.objects.filter(username=base).exists():
|
||||
return base
|
||||
for suffix in range(2, 10000):
|
||||
candidate = f"{base[: 150 - len(str(suffix)) - 1]}-{suffix}"
|
||||
if not user_model.objects.filter(username=candidate).exists():
|
||||
return candidate
|
||||
raise IdentitySyncError("无法生成唯一的本地账号")
|
||||
|
||||
|
||||
def _claim_list(value: Any) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
return []
|
||||
|
||||
|
||||
def _sync_business_groups(
|
||||
provider: EnterpriseIdentityProvider,
|
||||
identity: ExternalIdentity,
|
||||
external_groups: list[str],
|
||||
) -> None:
|
||||
mappings = list(provider.group_mappings.select_related("user_group"))
|
||||
managed_group_ids = {mapping.user_group_id for mapping in mappings}
|
||||
desired_group_ids = {
|
||||
mapping.user_group_id
|
||||
for mapping in mappings
|
||||
if mapping.external_group in set(external_groups) and mapping.user_group.is_active
|
||||
}
|
||||
if managed_group_ids:
|
||||
identity.user.business_groups.remove(*(managed_group_ids - desired_group_ids))
|
||||
if desired_group_ids:
|
||||
identity.user.business_groups.add(*desired_group_ids)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def sync_identity_claims(
|
||||
provider: EnterpriseIdentityProvider,
|
||||
claims: Mapping[str, Any],
|
||||
*,
|
||||
login_event: bool = False,
|
||||
active: bool | None = None,
|
||||
claim_mappings: Mapping[str, str] | None = None,
|
||||
) -> tuple[ExternalIdentity, bool]:
|
||||
"""按稳定 subject 同步本地账号、档案和受管业务组,不依据显示名关联身份。"""
|
||||
|
||||
mappings = dict(claim_mappings or resolved_claim_mappings(provider))
|
||||
subject = str(read_claim(claims, mappings["subject"], "")).strip()
|
||||
if not subject:
|
||||
raise IdentitySyncError("身份声明缺少稳定 subject")
|
||||
|
||||
identity = (
|
||||
ExternalIdentity.objects.select_for_update()
|
||||
.select_related("user")
|
||||
.filter(provider=provider, subject=subject)
|
||||
.first()
|
||||
)
|
||||
created = identity is None
|
||||
email = str(read_claim(claims, mappings["email"], "") or "").strip().lower()
|
||||
preferred_username = str(read_claim(claims, mappings["username"], "") or "").strip()
|
||||
display_name = str(read_claim(claims, mappings["display_name"], "") or "").strip()
|
||||
|
||||
if identity is None:
|
||||
if not provider.auto_create_users:
|
||||
raise IdentitySyncError("该身份提供方不允许自动创建账号")
|
||||
user_model = get_user_model()
|
||||
user = None
|
||||
if email and provider.configuration.get("allow_email_linking") is True:
|
||||
user = user_model.objects.filter(email__iexact=email).order_by("id").first()
|
||||
if user is None:
|
||||
username = _unique_username(provider, preferred_username or email, subject)
|
||||
user = user_model(username=username, email=email, first_name=display_name)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
identity = ExternalIdentity.objects.create(
|
||||
provider=provider,
|
||||
subject=subject,
|
||||
user=user,
|
||||
)
|
||||
|
||||
user = identity.user
|
||||
changed_fields: list[str] = []
|
||||
if provider.sync_profile:
|
||||
if email and user.email != email:
|
||||
user.email = email
|
||||
changed_fields.append("email")
|
||||
if display_name and user.first_name != display_name:
|
||||
user.first_name = display_name
|
||||
changed_fields.append("first_name")
|
||||
if active is not None and user.is_active != active:
|
||||
user.is_active = active
|
||||
changed_fields.append("is_active")
|
||||
if changed_fields:
|
||||
user.save(update_fields=changed_fields)
|
||||
if login_event and not user.is_active:
|
||||
raise IdentitySyncError("账号已停用")
|
||||
|
||||
now = timezone.now()
|
||||
identity.claims_snapshot = dict(claims)
|
||||
identity.last_synced_at = now
|
||||
update_fields = ["claims_snapshot", "last_synced_at", "updated_at"]
|
||||
if login_event:
|
||||
identity.last_login_at = now
|
||||
update_fields.append("last_login_at")
|
||||
identity.save(update_fields=update_fields)
|
||||
|
||||
if provider.sync_groups:
|
||||
_sync_business_groups(
|
||||
provider,
|
||||
identity,
|
||||
_claim_list(read_claim(claims, mappings["groups"], [])),
|
||||
)
|
||||
return identity, created
|
||||
|
||||
|
||||
def run_directory_sync(
|
||||
provider: EnterpriseIdentityProvider,
|
||||
records: list[dict[str, Any]],
|
||||
initiated_by,
|
||||
) -> EnterpriseDirectorySyncRun:
|
||||
"""逐条同步目录快照,隔离单条失败并保留最多一百条脱敏错误摘要。"""
|
||||
|
||||
run = EnterpriseDirectorySyncRun.objects.create(
|
||||
provider=provider,
|
||||
initiated_by=initiated_by,
|
||||
total_count=len(records),
|
||||
)
|
||||
errors: list[dict[str, str]] = []
|
||||
for index, record in enumerate(records):
|
||||
claims = {
|
||||
"sub": record.get("subject", ""),
|
||||
"preferred_username": record.get("username", ""),
|
||||
"email": record.get("email", ""),
|
||||
"name": record.get("display_name", ""),
|
||||
"groups": record.get("groups", []),
|
||||
}
|
||||
try:
|
||||
_, created = sync_identity_claims(
|
||||
provider,
|
||||
claims,
|
||||
active=bool(record.get("active", True)),
|
||||
claim_mappings=DEFAULT_CLAIM_MAPPINGS,
|
||||
)
|
||||
if created:
|
||||
run.created_count += 1
|
||||
else:
|
||||
run.updated_count += 1
|
||||
except (IdentitySyncError, IntegrityError) as error:
|
||||
run.failed_count += 1
|
||||
errors.append(
|
||||
{
|
||||
"index": str(index),
|
||||
"subject": str(record.get("subject", ""))[:255],
|
||||
"error": str(error),
|
||||
}
|
||||
)
|
||||
|
||||
run.errors = errors[:100]
|
||||
if run.failed_count == 0:
|
||||
run.status = EnterpriseDirectorySyncRun.Status.COMPLETED
|
||||
elif run.failed_count == run.total_count:
|
||||
run.status = EnterpriseDirectorySyncRun.Status.FAILED
|
||||
else:
|
||||
run.status = EnterpriseDirectorySyncRun.Status.PARTIAL
|
||||
run.finished_at = timezone.now()
|
||||
run.save(
|
||||
update_fields=[
|
||||
"created_count",
|
||||
"updated_count",
|
||||
"failed_count",
|
||||
"errors",
|
||||
"status",
|
||||
"finished_at",
|
||||
]
|
||||
)
|
||||
return run
|
||||
@@ -102,6 +102,24 @@ DEFAULT_MENUS: tuple[MenuSpec, ...] = (
|
||||
40,
|
||||
"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),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-27 08:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0002_reorganize_navigation_menus'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EnterpriseIdentityProvider',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(max_length=80, unique=True)),
|
||||
('name', models.CharField(max_length=120)),
|
||||
('provider_type', models.CharField(choices=[('oidc', 'OpenID Connect'), ('ldap', 'LDAP')], default='oidc', max_length=20)),
|
||||
('enabled', models.BooleanField(default=False)),
|
||||
('button_label', models.CharField(blank=True, max_length=120)),
|
||||
('issuer', models.URLField(blank=True, max_length=500)),
|
||||
('metadata_url', models.URLField(blank=True, max_length=500)),
|
||||
('client_id', models.CharField(blank=True, max_length=255)),
|
||||
('credential_encrypted', models.TextField(blank=True)),
|
||||
('scopes', models.JSONField(blank=True, default=list)),
|
||||
('claim_mappings', models.JSONField(blank=True, default=dict)),
|
||||
('configuration', models.JSONField(blank=True, default=dict)),
|
||||
('auto_create_users', models.BooleanField(default=True)),
|
||||
('sync_profile', models.BooleanField(default=True)),
|
||||
('sync_groups', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_identity_providers', to=settings.AUTH_USER_MODEL)),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_identity_providers', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EnterpriseGroupMapping',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('external_group', models.CharField(max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enterprise_identity_mappings', to='accounts.usergroup')),
|
||||
('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_mappings', to='accounts.enterpriseidentityprovider')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['provider_id', 'external_group'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EnterpriseDirectorySyncRun',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('running', '运行中'), ('completed', '已完成'), ('partial', '部分完成'), ('failed', '失败')], default='running', max_length=20)),
|
||||
('total_count', models.PositiveIntegerField(default=0)),
|
||||
('created_count', models.PositiveIntegerField(default=0)),
|
||||
('updated_count', models.PositiveIntegerField(default=0)),
|
||||
('failed_count', models.PositiveIntegerField(default=0)),
|
||||
('errors', models.JSONField(blank=True, default=list)),
|
||||
('started_at', models.DateTimeField(auto_now_add=True)),
|
||||
('finished_at', models.DateTimeField(blank=True, null=True)),
|
||||
('initiated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='enterprise_directory_sync_runs', to=settings.AUTH_USER_MODEL)),
|
||||
('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='directory_sync_runs', to='accounts.enterpriseidentityprovider')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-started_at', '-id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ExternalIdentity',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('subject', models.CharField(max_length=255)),
|
||||
('claims_snapshot', models.JSONField(blank=True, default=dict)),
|
||||
('last_login_at', models.DateTimeField(blank=True, null=True)),
|
||||
('last_synced_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('provider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_identities', to='accounts.enterpriseidentityprovider')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='external_identities', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['provider_id', 'subject'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='enterpriseidentityprovider',
|
||||
index=models.Index(fields=['provider_type', 'enabled'], name='acct_idp_type_enabled_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='enterprisegroupmapping',
|
||||
constraint=models.UniqueConstraint(fields=('provider', 'external_group'), name='acct_idp_external_group_uniq'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='enterprisedirectorysyncrun',
|
||||
index=models.Index(fields=['provider', 'started_at'], name='acct_dirsync_idp_started_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='externalidentity',
|
||||
index=models.Index(fields=['user', 'provider'], name='acct_identity_user_idp_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='externalidentity',
|
||||
constraint=models.UniqueConstraint(fields=('provider', 'subject'), name='acct_identity_provider_subject_uniq'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='externalidentity',
|
||||
constraint=models.UniqueConstraint(fields=('provider', 'user'), name='acct_identity_provider_user_uniq'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def add_identity_menu(apps, schema_editor):
|
||||
menu_item = apps.get_model("accounts", "MenuItem")
|
||||
parent = menu_item.objects.filter(code="system").first()
|
||||
menu_item.objects.update_or_create(
|
||||
code="system.identity",
|
||||
defaults={
|
||||
"title": "企业身份",
|
||||
"path": "#/system/identity",
|
||||
"icon": "shield",
|
||||
"parent": parent,
|
||||
"sort_order": 50,
|
||||
"permission_code": "system.setting.manage",
|
||||
"is_visible": True,
|
||||
"is_enabled": True,
|
||||
},
|
||||
)
|
||||
menu_item.objects.filter(code="system.audit").update(sort_order=60)
|
||||
menu_item.objects.filter(code="system.design").update(sort_order=70)
|
||||
|
||||
|
||||
def remove_identity_menu(apps, schema_editor):
|
||||
menu_item = apps.get_model("accounts", "MenuItem")
|
||||
menu_item.objects.filter(code="system.identity").delete()
|
||||
menu_item.objects.filter(code="system.audit").update(sort_order=50)
|
||||
menu_item.objects.filter(code="system.design").update(sort_order=60)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0003_enterpriseidentityprovider_enterprisegroupmapping_and_more")]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(add_identity_menu, remove_identity_menu),
|
||||
]
|
||||
@@ -44,3 +44,148 @@ class MenuItem(models.Model):
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title
|
||||
|
||||
|
||||
class EnterpriseIdentityProvider(models.Model):
|
||||
class ProviderType(models.TextChoices):
|
||||
OIDC = "oidc", "OpenID Connect"
|
||||
LDAP = "ldap", "LDAP"
|
||||
|
||||
slug = models.SlugField(max_length=80, unique=True)
|
||||
name = models.CharField(max_length=120)
|
||||
provider_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=ProviderType.choices,
|
||||
default=ProviderType.OIDC,
|
||||
)
|
||||
enabled = models.BooleanField(default=False)
|
||||
button_label = models.CharField(max_length=120, blank=True)
|
||||
issuer = models.URLField(max_length=500, blank=True)
|
||||
metadata_url = models.URLField(max_length=500, blank=True)
|
||||
client_id = models.CharField(max_length=255, blank=True)
|
||||
credential_encrypted = models.TextField(blank=True)
|
||||
scopes = models.JSONField(default=list, blank=True)
|
||||
claim_mappings = models.JSONField(default=dict, blank=True)
|
||||
configuration = models.JSONField(default=dict, blank=True)
|
||||
auto_create_users = models.BooleanField(default=True)
|
||||
sync_profile = models.BooleanField(default=True)
|
||||
sync_groups = models.BooleanField(default=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_identity_providers",
|
||||
)
|
||||
updated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="updated_identity_providers",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name", "id"]
|
||||
indexes = [
|
||||
models.Index(fields=["provider_type", "enabled"], name="acct_idp_type_enabled_idx"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class ExternalIdentity(models.Model):
|
||||
provider = models.ForeignKey(
|
||||
EnterpriseIdentityProvider,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="external_identities",
|
||||
)
|
||||
subject = models.CharField(max_length=255)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="external_identities",
|
||||
)
|
||||
claims_snapshot = models.JSONField(default=dict, blank=True)
|
||||
last_login_at = models.DateTimeField(null=True, blank=True)
|
||||
last_synced_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["provider_id", "subject"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["provider", "subject"],
|
||||
name="acct_identity_provider_subject_uniq",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["provider", "user"],
|
||||
name="acct_identity_provider_user_uniq",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "provider"], name="acct_identity_user_idp_idx"),
|
||||
]
|
||||
|
||||
|
||||
class EnterpriseGroupMapping(models.Model):
|
||||
provider = models.ForeignKey(
|
||||
EnterpriseIdentityProvider,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="group_mappings",
|
||||
)
|
||||
external_group = models.CharField(max_length=255)
|
||||
user_group = models.ForeignKey(
|
||||
UserGroup,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="enterprise_identity_mappings",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["provider_id", "external_group"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["provider", "external_group"],
|
||||
name="acct_idp_external_group_uniq",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class EnterpriseDirectorySyncRun(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
RUNNING = "running", "运行中"
|
||||
COMPLETED = "completed", "已完成"
|
||||
PARTIAL = "partial", "部分完成"
|
||||
FAILED = "failed", "失败"
|
||||
|
||||
provider = models.ForeignKey(
|
||||
EnterpriseIdentityProvider,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="directory_sync_runs",
|
||||
)
|
||||
status = models.CharField(max_length=20, choices=Status.choices, default=Status.RUNNING)
|
||||
total_count = models.PositiveIntegerField(default=0)
|
||||
created_count = models.PositiveIntegerField(default=0)
|
||||
updated_count = models.PositiveIntegerField(default=0)
|
||||
failed_count = models.PositiveIntegerField(default=0)
|
||||
errors = models.JSONField(default=list, blank=True)
|
||||
initiated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="enterprise_directory_sync_runs",
|
||||
)
|
||||
started_at = models.DateTimeField(auto_now_add=True)
|
||||
finished_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-started_at", "-id"]
|
||||
indexes = [
|
||||
models.Index(fields=["provider", "started_at"], name="acct_dirsync_idp_started_idx"),
|
||||
]
|
||||
|
||||
72
backend/apps/accounts/oidc.py
Normal file
72
backend/apps/accounts/oidc.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""OIDC 客户端构建和安全重定向辅助函数。"""
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from apps.accounts.models import EnterpriseIdentityProvider
|
||||
from apps.modelhub.crypto import decrypt_secret
|
||||
|
||||
|
||||
class OIDCConfigurationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def provider_metadata_url(provider: EnterpriseIdentityProvider) -> str:
|
||||
if provider.metadata_url:
|
||||
return provider.metadata_url
|
||||
if provider.issuer:
|
||||
return f"{provider.issuer.rstrip('/')}/.well-known/openid-configuration"
|
||||
raise OIDCConfigurationError("OIDC 提供方缺少发现文档地址或 issuer")
|
||||
|
||||
|
||||
def build_oidc_client(provider: EnterpriseIdentityProvider):
|
||||
"""构建依赖发现文档和加密凭据的 OIDC 客户端。"""
|
||||
|
||||
try:
|
||||
from authlib.integrations.django_client import OAuth
|
||||
except ImportError as error:
|
||||
raise OIDCConfigurationError("服务端未安装 Authlib") from error
|
||||
|
||||
if not provider.client_id:
|
||||
raise OIDCConfigurationError("OIDC 提供方缺少 client_id")
|
||||
if not provider.credential_encrypted:
|
||||
raise OIDCConfigurationError("OIDC 提供方缺少 client secret")
|
||||
scopes = provider.scopes or ["openid", "profile", "email"]
|
||||
if "openid" not in scopes:
|
||||
raise OIDCConfigurationError("OIDC scopes 必须包含 openid")
|
||||
oauth = OAuth()
|
||||
return oauth.register(
|
||||
name=f"enterprise_oidc_{provider.pk}",
|
||||
client_id=provider.client_id,
|
||||
client_secret=decrypt_secret(provider.credential_encrypted),
|
||||
server_metadata_url=provider_metadata_url(provider),
|
||||
client_kwargs={
|
||||
"scope": " ".join(scopes),
|
||||
"token_endpoint_auth_method": provider.configuration.get(
|
||||
"token_endpoint_auth_method",
|
||||
"client_secret_basic",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def callback_uri(request, provider: EnterpriseIdentityProvider) -> str:
|
||||
path = f"/api/identity/auth/oidc/{provider.slug}/callback"
|
||||
if settings.OIDC_CALLBACK_BASE_URL:
|
||||
return f"{settings.OIDC_CALLBACK_BASE_URL}{path}"
|
||||
return request.build_absolute_uri(path)
|
||||
|
||||
|
||||
def normalize_return_to(value: str | None) -> str:
|
||||
"""只允许站内绝对路径,阻断协议相对地址和外部开放重定向。"""
|
||||
|
||||
if not value or not value.startswith("/") or value.startswith("//") or len(value) > 500:
|
||||
return "/"
|
||||
return value
|
||||
|
||||
|
||||
def frontend_redirect(return_to: str = "/", error: str = "") -> str:
|
||||
if error:
|
||||
return f"{settings.FRONTEND_APP_URL}/#/login?auth_error={quote(error[:240])}"
|
||||
return f"{settings.FRONTEND_APP_URL}/#{normalize_return_to(return_to)}"
|
||||
238
backend/apps/accounts/tests/test_enterprise_identity.py
Normal file
238
backend/apps/accounts/tests/test_enterprise_identity.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.http import HttpResponseRedirect
|
||||
|
||||
from apps.accounts.models import (
|
||||
EnterpriseGroupMapping,
|
||||
EnterpriseIdentityProvider,
|
||||
ExternalIdentity,
|
||||
UserGroup,
|
||||
)
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.modelhub.crypto import encrypt_secret
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def login_admin(client):
|
||||
call_command("bootstrap_governance")
|
||||
admin = get_user_model().objects.get(username="admin")
|
||||
client.force_login(admin)
|
||||
return admin
|
||||
|
||||
|
||||
def oidc_provider(**overrides):
|
||||
values = {
|
||||
"slug": "corporate",
|
||||
"name": "企业统一身份",
|
||||
"provider_type": "oidc",
|
||||
"enabled": True,
|
||||
"issuer": "https://idp.example.com",
|
||||
"client_id": "ticket-platform",
|
||||
"credential_encrypted": encrypt_secret("client-secret"),
|
||||
"scopes": ["openid", "profile", "email"],
|
||||
"claim_mappings": {},
|
||||
"configuration": {},
|
||||
}
|
||||
values.update(overrides)
|
||||
return EnterpriseIdentityProvider.objects.create(**values)
|
||||
|
||||
|
||||
def test_provider_management_encrypts_credentials_and_exposes_safe_public_list(client):
|
||||
login_admin(client)
|
||||
|
||||
response = client.post(
|
||||
"/api/identity/providers",
|
||||
data={
|
||||
"slug": "corp-oidc",
|
||||
"name": "公司 SSO",
|
||||
"provider_type": "oidc",
|
||||
"enabled": True,
|
||||
"issuer": "https://idp.example.com",
|
||||
"client_id": "ticket-platform",
|
||||
"credential": "top-secret-value",
|
||||
"scopes": ["openid", "profile", "email"],
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["credential_set"] is True
|
||||
assert "top-secret-value" not in str(payload)
|
||||
provider = EnterpriseIdentityProvider.objects.get(slug="corp-oidc")
|
||||
assert provider.credential_encrypted != "top-secret-value"
|
||||
|
||||
client.logout()
|
||||
public_response = client.get("/api/identity/auth/providers")
|
||||
assert public_response.status_code == 200
|
||||
assert public_response.json() == [
|
||||
{
|
||||
"slug": "corp-oidc",
|
||||
"name": "公司 SSO",
|
||||
"button_label": "使用 公司 SSO 登录",
|
||||
"provider_type": "oidc",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_provider_rejects_plaintext_secrets_inside_configuration(client):
|
||||
login_admin(client)
|
||||
|
||||
response = client.post(
|
||||
"/api/identity/providers",
|
||||
data={
|
||||
"slug": "unsafe",
|
||||
"name": "不安全配置",
|
||||
"provider_type": "ldap",
|
||||
"configuration": {
|
||||
"server_uri": "ldaps://directory.example.com",
|
||||
"bind_password": "must-not-be-plain",
|
||||
},
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert not EnterpriseIdentityProvider.objects.filter(slug="unsafe").exists()
|
||||
|
||||
|
||||
def test_directory_sync_is_idempotent_and_maps_enterprise_groups(client):
|
||||
login_admin(client)
|
||||
provider = oidc_provider(
|
||||
enabled=False,
|
||||
claim_mappings={
|
||||
"subject": "employee.id",
|
||||
"username": "employee.account",
|
||||
"email": "employee.mail",
|
||||
"display_name": "employee.name",
|
||||
"groups": "organization.memberships",
|
||||
},
|
||||
)
|
||||
support = UserGroup.objects.create(name="一线支持", code="support_l1")
|
||||
EnterpriseGroupMapping.objects.create(
|
||||
provider=provider,
|
||||
external_group="IT-Support",
|
||||
user_group=support,
|
||||
)
|
||||
url = f"/api/identity/providers/{provider.id}/directory-sync"
|
||||
record = {
|
||||
"subject": "employee-1001",
|
||||
"username": "zhangsan",
|
||||
"email": "zhangsan@example.com",
|
||||
"display_name": "张三",
|
||||
"groups": ["IT-Support"],
|
||||
}
|
||||
|
||||
first = client.post(
|
||||
url,
|
||||
data={"records": [record]},
|
||||
content_type="application/json",
|
||||
)
|
||||
second = client.post(
|
||||
url,
|
||||
data={"records": [{**record, "display_name": "张三(支持)"}]},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert first.json()["created_count"] == 1
|
||||
assert second.status_code == 200
|
||||
assert second.json()["updated_count"] == 1
|
||||
assert ExternalIdentity.objects.filter(provider=provider).count() == 1
|
||||
identity = ExternalIdentity.objects.select_related("user").get(provider=provider)
|
||||
assert identity.user.first_name == "张三(支持)"
|
||||
assert identity.user.business_groups.filter(id=support.id).exists()
|
||||
assert not identity.user.has_usable_password()
|
||||
|
||||
|
||||
def test_oidc_callback_logs_in_user_and_reuses_external_identity(client):
|
||||
provider = oidc_provider()
|
||||
support = UserGroup.objects.create(name="企业支持组", code="enterprise_support")
|
||||
EnterpriseGroupMapping.objects.create(
|
||||
provider=provider,
|
||||
external_group="support",
|
||||
user_group=support,
|
||||
)
|
||||
|
||||
class FakeOIDCClient:
|
||||
def authorize_access_token(self, request):
|
||||
return {
|
||||
"userinfo": {
|
||||
"sub": "stable-subject",
|
||||
"preferred_username": "corp-user",
|
||||
"email": "corp-user@example.com",
|
||||
"name": "企业用户",
|
||||
"groups": ["support"],
|
||||
}
|
||||
}
|
||||
|
||||
session = client.session
|
||||
session[f"identity_return_to:{provider.slug}"] = "/tickets"
|
||||
session.save()
|
||||
callback = f"/api/identity/auth/oidc/{provider.slug}/callback"
|
||||
|
||||
with patch(
|
||||
"apps.accounts.identity_api.build_oidc_client",
|
||||
return_value=FakeOIDCClient(),
|
||||
):
|
||||
first = client.get(callback)
|
||||
client.logout()
|
||||
second = client.get(callback)
|
||||
|
||||
assert first.status_code == 302
|
||||
assert first.url.endswith("/#/tickets")
|
||||
assert second.status_code == 302
|
||||
assert ExternalIdentity.objects.filter(provider=provider).count() == 1
|
||||
identity = ExternalIdentity.objects.select_related("user").get(provider=provider)
|
||||
assert identity.user.business_groups.filter(id=support.id).exists()
|
||||
assert OperationAuditLog.objects.filter(
|
||||
action="oidc_login",
|
||||
target_id=str(identity.id),
|
||||
).count() == 2
|
||||
|
||||
|
||||
def test_oidc_start_uses_server_side_client_and_callback_failure_is_safe(client):
|
||||
provider = oidc_provider()
|
||||
|
||||
class FakeOIDCClient:
|
||||
def authorize_redirect(self, request, redirect_uri):
|
||||
assert redirect_uri.endswith(f"/api/identity/auth/oidc/{provider.slug}/callback")
|
||||
return HttpResponseRedirect("https://idp.example.com/authorize?state=managed")
|
||||
|
||||
def authorize_access_token(self, request):
|
||||
return {"access_token": "must-never-be-audited"}
|
||||
|
||||
with patch(
|
||||
"apps.accounts.identity_api.build_oidc_client",
|
||||
return_value=FakeOIDCClient(),
|
||||
):
|
||||
start = client.get(
|
||||
f"/api/identity/auth/oidc/{provider.slug}/start",
|
||||
{"return_to": "//evil.example.com"},
|
||||
)
|
||||
callback = client.get(f"/api/identity/auth/oidc/{provider.slug}/callback")
|
||||
|
||||
assert start.status_code == 302
|
||||
assert start.url.startswith("https://idp.example.com/authorize")
|
||||
assert callback.status_code == 302
|
||||
assert "auth_error=" in callback.url
|
||||
audit = OperationAuditLog.objects.get(action="oidc_callback_failed")
|
||||
assert "must-never-be-audited" not in str(audit.request_snapshot)
|
||||
|
||||
|
||||
def test_identity_governance_requires_permission(client):
|
||||
provider = oidc_provider(enabled=False)
|
||||
|
||||
assert client.get("/api/identity/providers").status_code == 401
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/identity/providers/{provider.id}/directory-sync",
|
||||
data={"records": []},
|
||||
content_type="application/json",
|
||||
).status_code
|
||||
== 401
|
||||
)
|
||||
1
backend/apps/channels/__init__.py
Normal file
1
backend/apps/channels/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
242
backend/apps/channels/api.py
Normal file
242
backend/apps/channels/api.py
Normal file
@@ -0,0 +1,242 @@
|
||||
from django.db import transaction
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from apps.accounts.api import require_permission
|
||||
from apps.channels.models import ChannelEndpoint, ChannelMessage
|
||||
from apps.channels.schemas import (
|
||||
ChannelEndpointIn,
|
||||
ChannelEndpointOut,
|
||||
ChannelEndpointUpdateIn,
|
||||
ChannelInboundIn,
|
||||
ChannelMessageOut,
|
||||
ChannelOutboundIn,
|
||||
ChannelReceiptIn,
|
||||
)
|
||||
from apps.channels.services import (
|
||||
ChannelError,
|
||||
ingest_inbound_message,
|
||||
queue_outbound_message,
|
||||
record_channel_receipt,
|
||||
verify_webhook_signature,
|
||||
)
|
||||
from apps.modelhub.crypto import encrypt_secret
|
||||
|
||||
router = Router(tags=["channels"])
|
||||
|
||||
|
||||
def serialize_endpoint(endpoint: ChannelEndpoint) -> dict:
|
||||
return {
|
||||
"id": endpoint.id,
|
||||
"public_id": endpoint.public_id,
|
||||
"name": endpoint.name,
|
||||
"adapter_type": endpoint.adapter_type,
|
||||
"outbound_url": endpoint.outbound_url,
|
||||
"webhook_secret_set": bool(endpoint.webhook_secret_encrypted),
|
||||
"outbound_token_set": bool(endpoint.outbound_token_encrypted),
|
||||
"enabled": endpoint.enabled,
|
||||
"created_at": endpoint.created_at,
|
||||
"updated_at": endpoint.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_message(message: ChannelMessage) -> dict:
|
||||
return {
|
||||
"public_id": message.public_id,
|
||||
"endpoint_id": message.endpoint_id,
|
||||
"endpoint_name": message.endpoint.name,
|
||||
"conversation_public_id": (
|
||||
message.conversation.public_id if message.conversation else None
|
||||
),
|
||||
"external_conversation_id": (
|
||||
message.conversation.external_conversation_id
|
||||
if message.conversation
|
||||
else ""
|
||||
),
|
||||
"ticket_id": message.ticket_id,
|
||||
"customer_reply_task_id": message.customer_reply_task_id,
|
||||
"reply_revision": message.reply_revision,
|
||||
"direction": message.direction,
|
||||
"external_message_id": message.external_message_id,
|
||||
"idempotency_key": message.idempotency_key,
|
||||
"sender": message.sender,
|
||||
"message_type": message.message_type,
|
||||
"text": message.text,
|
||||
"payload": message.payload,
|
||||
"status": message.status,
|
||||
"error_message": message.error_message,
|
||||
"occurred_at": message.occurred_at,
|
||||
"sent_at": message.sent_at,
|
||||
"delivered_at": message.delivered_at,
|
||||
"created_at": message.created_at,
|
||||
"updated_at": message.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def get_endpoint(public_id: str) -> ChannelEndpoint:
|
||||
endpoint = ChannelEndpoint.objects.filter(public_id=public_id).first()
|
||||
if endpoint is None:
|
||||
raise HttpError(404, "渠道不存在")
|
||||
return endpoint
|
||||
|
||||
|
||||
@router.get("/endpoints", response=list[ChannelEndpointOut])
|
||||
def list_channel_endpoints(request):
|
||||
require_permission(request, "channel.view")
|
||||
return [serialize_endpoint(item) for item in ChannelEndpoint.objects.all()]
|
||||
|
||||
|
||||
@router.post("/endpoints", response=ChannelEndpointOut)
|
||||
def create_channel_endpoint(request, payload: ChannelEndpointIn):
|
||||
require_permission(request, "channel.manage")
|
||||
if payload.adapter_type != "generic_webhook":
|
||||
raise HttpError(400, "首期仅支持 generic_webhook 适配器")
|
||||
if not payload.webhook_secret.strip():
|
||||
raise HttpError(400, "Webhook 密钥不能为空")
|
||||
endpoint = ChannelEndpoint.objects.create(
|
||||
name=payload.name,
|
||||
adapter_type=payload.adapter_type,
|
||||
outbound_url=payload.outbound_url,
|
||||
webhook_secret_encrypted=encrypt_secret(payload.webhook_secret),
|
||||
outbound_token_encrypted=(
|
||||
encrypt_secret(payload.outbound_token) if payload.outbound_token else ""
|
||||
),
|
||||
enabled=payload.enabled,
|
||||
created_by=request.user,
|
||||
)
|
||||
return serialize_endpoint(endpoint)
|
||||
|
||||
|
||||
@router.patch("/endpoints/{endpoint_id}", response=ChannelEndpointOut)
|
||||
def update_channel_endpoint(
|
||||
request,
|
||||
endpoint_id: int,
|
||||
payload: ChannelEndpointUpdateIn,
|
||||
):
|
||||
require_permission(request, "channel.manage")
|
||||
endpoint = ChannelEndpoint.objects.filter(id=endpoint_id).first()
|
||||
if endpoint is None:
|
||||
raise HttpError(404, "渠道不存在")
|
||||
changes = payload.dict(exclude_unset=True)
|
||||
for field in ("name", "outbound_url", "enabled"):
|
||||
if field in changes:
|
||||
setattr(endpoint, field, changes[field])
|
||||
if changes.get("webhook_secret"):
|
||||
endpoint.webhook_secret_encrypted = encrypt_secret(changes["webhook_secret"])
|
||||
if changes.get("outbound_token"):
|
||||
endpoint.outbound_token_encrypted = encrypt_secret(changes["outbound_token"])
|
||||
endpoint.save()
|
||||
return serialize_endpoint(endpoint)
|
||||
|
||||
|
||||
@router.get("/messages", response=list[ChannelMessageOut])
|
||||
def list_channel_messages(
|
||||
request,
|
||||
endpoint_id: int | None = None,
|
||||
direction: str = "",
|
||||
status: str = "",
|
||||
):
|
||||
require_permission(request, "channel.view")
|
||||
queryset = ChannelMessage.objects.select_related("endpoint", "conversation")
|
||||
if endpoint_id:
|
||||
queryset = queryset.filter(endpoint_id=endpoint_id)
|
||||
if direction:
|
||||
queryset = queryset.filter(direction=direction)
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
return [serialize_message(item) for item in queryset[:100]]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/endpoints/{endpoint_id}/messages",
|
||||
response=ChannelMessageOut,
|
||||
)
|
||||
def send_channel_message(request, endpoint_id: int, payload: ChannelOutboundIn):
|
||||
require_permission(request, "channel.send")
|
||||
require_permission(request, "ticket.reply")
|
||||
endpoint = ChannelEndpoint.objects.filter(id=endpoint_id).first()
|
||||
if endpoint is None:
|
||||
raise HttpError(404, "渠道不存在")
|
||||
try:
|
||||
message = queue_outbound_message(
|
||||
endpoint,
|
||||
payload.dict(),
|
||||
actor=request.user,
|
||||
)
|
||||
except ChannelError as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
message = ChannelMessage.objects.select_related("endpoint", "conversation").get(
|
||||
id=message.id
|
||||
)
|
||||
return serialize_message(message)
|
||||
|
||||
|
||||
@router.post("/messages/{public_id}/retry", response=ChannelMessageOut)
|
||||
def retry_channel_message(request, public_id: str):
|
||||
require_permission(request, "channel.send")
|
||||
require_permission(request, "ticket.reply")
|
||||
message = (
|
||||
ChannelMessage.objects.select_related("endpoint", "conversation")
|
||||
.filter(public_id=public_id, direction="outbound")
|
||||
.first()
|
||||
)
|
||||
if message is None:
|
||||
raise HttpError(404, "出站消息不存在")
|
||||
if message.status != "failed":
|
||||
raise HttpError(409, "仅失败消息可以重试")
|
||||
message.status = "queued"
|
||||
message.error_message = ""
|
||||
message.save(update_fields=["status", "error_message", "updated_at"])
|
||||
|
||||
def enqueue_delivery():
|
||||
from apps.channels.tasks import deliver_channel_message_task
|
||||
|
||||
deliver_channel_message_task.delay(message.id)
|
||||
|
||||
transaction.on_commit(enqueue_delivery)
|
||||
return serialize_message(message)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webhooks/{public_id}/messages",
|
||||
response=ChannelMessageOut,
|
||||
auth=None,
|
||||
)
|
||||
def receive_channel_message(request, public_id: str, payload: ChannelInboundIn):
|
||||
endpoint = get_endpoint(public_id)
|
||||
try:
|
||||
verify_webhook_signature(
|
||||
endpoint,
|
||||
request.body,
|
||||
request.headers.get("X-Channel-Signature", ""),
|
||||
)
|
||||
except ChannelError as error:
|
||||
raise HttpError(401, str(error)) from error
|
||||
try:
|
||||
message = ingest_inbound_message(endpoint, payload.dict())
|
||||
except ChannelError as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
return serialize_message(
|
||||
ChannelMessage.objects.select_related("endpoint", "conversation").get(
|
||||
id=message.id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/webhooks/{public_id}/receipts",
|
||||
response={200: dict},
|
||||
auth=None,
|
||||
)
|
||||
def receive_channel_receipt(request, public_id: str, payload: ChannelReceiptIn):
|
||||
endpoint = get_endpoint(public_id)
|
||||
try:
|
||||
verify_webhook_signature(
|
||||
endpoint,
|
||||
request.body,
|
||||
request.headers.get("X-Channel-Signature", ""),
|
||||
)
|
||||
receipt = record_channel_receipt(endpoint, payload.dict())
|
||||
except ChannelError as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
return 200, {"status": "accepted", "receipt_id": receipt.id}
|
||||
6
backend/apps/channels/apps.py
Normal file
6
backend/apps/channels/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ChannelsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.channels"
|
||||
112
backend/apps/channels/migrations/0001_initial.py
Normal file
112
backend/apps/channels/migrations/0001_initial.py
Normal file
@@ -0,0 +1,112 @@
|
||||
# Generated by Django 5.2.8 on 2026-07-27
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("tickets", "0013_ticketsupportanalysis_applied_solution_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ChannelEndpoint",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
("name", models.CharField(max_length=120, unique=True)),
|
||||
("adapter_type", models.CharField(default="generic_webhook", max_length=40)),
|
||||
("outbound_url", models.URLField(blank=True, max_length=500)),
|
||||
("webhook_secret_encrypted", models.TextField(blank=True)),
|
||||
("outbound_token_encrypted", models.TextField(blank=True)),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("created_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_channel_endpoints", to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={"ordering": ["name"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ChannelConversation",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
("external_conversation_id", models.CharField(max_length=200)),
|
||||
("title", models.CharField(blank=True, max_length=200)),
|
||||
("participants", models.JSONField(blank=True, default=list)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("endpoint", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="conversations", to="channels.channelendpoint")),
|
||||
("ticket", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="channel_conversations", to="tickets.ticket")),
|
||||
],
|
||||
options={"ordering": ["-updated_at"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ChannelMessage",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
("direction", models.CharField(max_length=16)),
|
||||
("external_message_id", models.CharField(blank=True, max_length=200)),
|
||||
("idempotency_key", models.CharField(blank=True, max_length=200)),
|
||||
("sender", models.CharField(blank=True, max_length=160)),
|
||||
("message_type", models.CharField(default="text", max_length=24)),
|
||||
("text", models.TextField(blank=True)),
|
||||
("payload", models.JSONField(blank=True, default=dict)),
|
||||
("status", models.CharField(max_length=24)),
|
||||
("error_message", models.TextField(blank=True)),
|
||||
("occurred_at", models.DateTimeField(blank=True, null=True)),
|
||||
("sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("delivered_at", models.DateTimeField(blank=True, null=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("conversation", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="messages", to="channels.channelconversation")),
|
||||
("created_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="created_channel_messages", to=settings.AUTH_USER_MODEL)),
|
||||
("endpoint", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="messages", to="channels.channelendpoint")),
|
||||
("ticket", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="channel_messages", to="tickets.ticket")),
|
||||
],
|
||||
options={"ordering": ["-created_at"]},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ChannelReceipt",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("receipt_type", models.CharField(max_length=24)),
|
||||
("external_receipt_id", models.CharField(max_length=200)),
|
||||
("payload", models.JSONField(blank=True, default=dict)),
|
||||
("received_at", models.DateTimeField(auto_now_add=True)),
|
||||
("message", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="receipts", to="channels.channelmessage")),
|
||||
],
|
||||
options={"ordering": ["received_at"]},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="channelconversation",
|
||||
constraint=models.UniqueConstraint(fields=("endpoint", "external_conversation_id"), name="unique_channel_external_conversation"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="channelmessage",
|
||||
index=models.Index(fields=["endpoint", "direction", "created_at"], name="channels_ch_endpoin_30d460_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="channelmessage",
|
||||
index=models.Index(fields=["status", "created_at"], name="channels_ch_status_eeefec_idx"),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="channelmessage",
|
||||
constraint=models.UniqueConstraint(condition=models.Q(("external_message_id__gt", "")), fields=("endpoint", "direction", "external_message_id"), name="unique_channel_external_message"),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="channelmessage",
|
||||
constraint=models.UniqueConstraint(condition=models.Q(("idempotency_key__gt", "")), fields=("endpoint", "direction", "idempotency_key"), name="unique_channel_message_idempotency"),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="channelreceipt",
|
||||
constraint=models.UniqueConstraint(fields=("message", "receipt_type", "external_receipt_id"), name="unique_channel_message_receipt"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("channels", "0001_initial"),
|
||||
("tickets", "0014_customerreplytask_channel_approval"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="channelmessage",
|
||||
name="customer_reply_task",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="channel_messages",
|
||||
to="tickets.customerreplytask",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="channelmessage",
|
||||
name="reply_revision",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="channelmessage",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("customer_reply_task", "reply_revision"),
|
||||
condition=Q(
|
||||
direction="outbound",
|
||||
customer_reply_task__isnull=False,
|
||||
),
|
||||
name="unique_channel_reply_revision",
|
||||
),
|
||||
),
|
||||
]
|
||||
1
backend/apps/channels/migrations/__init__.py
Normal file
1
backend/apps/channels/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
157
backend/apps/channels/models.py
Normal file
157
backend/apps/channels/models.py
Normal file
@@ -0,0 +1,157 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
class ChannelEndpoint(models.Model):
|
||||
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
name = models.CharField(max_length=120, unique=True)
|
||||
adapter_type = models.CharField(max_length=40, default="generic_webhook")
|
||||
outbound_url = models.URLField(max_length=500, blank=True)
|
||||
webhook_secret_encrypted = models.TextField(blank=True)
|
||||
outbound_token_encrypted = models.TextField(blank=True)
|
||||
enabled = models.BooleanField(default=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_channel_endpoints",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
|
||||
class ChannelConversation(models.Model):
|
||||
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
endpoint = models.ForeignKey(
|
||||
ChannelEndpoint,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="conversations",
|
||||
)
|
||||
external_conversation_id = models.CharField(max_length=200)
|
||||
title = models.CharField(max_length=200, blank=True)
|
||||
participants = models.JSONField(default=list, blank=True)
|
||||
ticket = models.ForeignKey(
|
||||
"tickets.Ticket",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="channel_conversations",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["endpoint", "external_conversation_id"],
|
||||
name="unique_channel_external_conversation",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class ChannelMessage(models.Model):
|
||||
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
endpoint = models.ForeignKey(
|
||||
ChannelEndpoint,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="messages",
|
||||
)
|
||||
conversation = models.ForeignKey(
|
||||
ChannelConversation,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="messages",
|
||||
)
|
||||
ticket = models.ForeignKey(
|
||||
"tickets.Ticket",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="channel_messages",
|
||||
)
|
||||
customer_reply_task = models.ForeignKey(
|
||||
"tickets.CustomerReplyTask",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="channel_messages",
|
||||
)
|
||||
reply_revision = models.PositiveIntegerField(null=True, blank=True)
|
||||
direction = models.CharField(max_length=16)
|
||||
external_message_id = models.CharField(max_length=200, blank=True)
|
||||
idempotency_key = models.CharField(max_length=200, blank=True)
|
||||
sender = models.CharField(max_length=160, blank=True)
|
||||
message_type = models.CharField(max_length=24, default="text")
|
||||
text = models.TextField(blank=True)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
status = models.CharField(max_length=24)
|
||||
error_message = models.TextField(blank=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="created_channel_messages",
|
||||
)
|
||||
occurred_at = models.DateTimeField(null=True, blank=True)
|
||||
sent_at = models.DateTimeField(null=True, blank=True)
|
||||
delivered_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["endpoint", "direction", "external_message_id"],
|
||||
condition=Q(external_message_id__gt=""),
|
||||
name="unique_channel_external_message",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["endpoint", "direction", "idempotency_key"],
|
||||
condition=Q(idempotency_key__gt=""),
|
||||
name="unique_channel_message_idempotency",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["customer_reply_task", "reply_revision"],
|
||||
condition=Q(
|
||||
direction="outbound",
|
||||
customer_reply_task__isnull=False,
|
||||
),
|
||||
name="unique_channel_reply_revision",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["endpoint", "direction", "created_at"]),
|
||||
models.Index(fields=["status", "created_at"]),
|
||||
]
|
||||
|
||||
|
||||
class ChannelReceipt(models.Model):
|
||||
message = models.ForeignKey(
|
||||
ChannelMessage,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="receipts",
|
||||
)
|
||||
receipt_type = models.CharField(max_length=24)
|
||||
external_receipt_id = models.CharField(max_length=200)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
received_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["received_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["message", "receipt_type", "external_receipt_id"],
|
||||
name="unique_channel_message_receipt",
|
||||
)
|
||||
]
|
||||
86
backend/apps/channels/schemas.py
Normal file
86
backend/apps/channels/schemas.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class ChannelEndpointIn(Schema):
|
||||
name: str
|
||||
adapter_type: str = "generic_webhook"
|
||||
outbound_url: str = ""
|
||||
webhook_secret: str
|
||||
outbound_token: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ChannelEndpointOut(Schema):
|
||||
id: int
|
||||
public_id: UUID
|
||||
name: str
|
||||
adapter_type: str
|
||||
outbound_url: str
|
||||
webhook_secret_set: bool
|
||||
outbound_token_set: bool
|
||||
enabled: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ChannelEndpointUpdateIn(Schema):
|
||||
name: str | None = None
|
||||
outbound_url: str | None = None
|
||||
webhook_secret: str | None = None
|
||||
outbound_token: str | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class ChannelInboundIn(Schema):
|
||||
external_message_id: str
|
||||
external_conversation_id: str
|
||||
text: str
|
||||
sender: str = ""
|
||||
title: str = ""
|
||||
participants: list[str] = []
|
||||
occurred_at: datetime | None = None
|
||||
payload: dict[str, Any] = {}
|
||||
|
||||
|
||||
class ChannelReceiptIn(Schema):
|
||||
external_message_id: str
|
||||
external_receipt_id: str
|
||||
receipt_type: str
|
||||
payload: dict[str, Any] = {}
|
||||
|
||||
|
||||
class ChannelOutboundIn(Schema):
|
||||
external_conversation_id: str
|
||||
idempotency_key: str
|
||||
customer_reply_task_id: int
|
||||
expected_revision: int
|
||||
payload: dict[str, Any] = {}
|
||||
|
||||
|
||||
class ChannelMessageOut(Schema):
|
||||
public_id: UUID
|
||||
endpoint_id: int
|
||||
endpoint_name: str
|
||||
conversation_public_id: UUID | None
|
||||
external_conversation_id: str
|
||||
ticket_id: int | None
|
||||
customer_reply_task_id: int | None
|
||||
reply_revision: int | None
|
||||
direction: str
|
||||
external_message_id: str
|
||||
idempotency_key: str
|
||||
sender: str
|
||||
message_type: str
|
||||
text: str
|
||||
payload: dict[str, Any]
|
||||
status: str
|
||||
error_message: str
|
||||
occurred_at: datetime | None
|
||||
sent_at: datetime | None
|
||||
delivered_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
375
backend/apps/channels/services.py
Normal file
375
backend/apps/channels/services.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""企业渠道消息的签名校验、幂等收发、回执和客户回复任务联动。"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.channels.models import (
|
||||
ChannelConversation,
|
||||
ChannelEndpoint,
|
||||
ChannelMessage,
|
||||
ChannelReceipt,
|
||||
)
|
||||
from apps.modelhub.crypto import decrypt_secret
|
||||
from apps.tickets.models import CustomerReplyTask, TicketActorSource
|
||||
from apps.tickets.replies import (
|
||||
CustomerReplyError,
|
||||
approve_customer_reply_for_channel,
|
||||
confirm_customer_reply_sent,
|
||||
)
|
||||
|
||||
|
||||
class ChannelError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def verify_webhook_signature(endpoint: ChannelEndpoint, body: bytes, signature: str) -> None:
|
||||
"""使用常量时间比较验证原始请求体的 HMAC-SHA256 签名。"""
|
||||
|
||||
if not endpoint.webhook_secret_encrypted:
|
||||
raise ChannelError("渠道未配置 Webhook 密钥")
|
||||
secret = decrypt_secret(endpoint.webhook_secret_encrypted).encode()
|
||||
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
normalized = signature.removeprefix("sha256=").strip()
|
||||
if not normalized or not hmac.compare_digest(expected, normalized):
|
||||
raise ChannelError("Webhook 签名无效")
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def ingest_inbound_message(endpoint: ChannelEndpoint, payload: dict) -> ChannelMessage:
|
||||
"""以渠道消息 ID 幂等写入入站消息和会话,不在此处隐式创建工单。"""
|
||||
|
||||
endpoint = ChannelEndpoint.objects.select_for_update().get(id=endpoint.id)
|
||||
if not endpoint.enabled:
|
||||
raise ChannelError("渠道已停用")
|
||||
external_message_id = str(payload.get("external_message_id") or "").strip()
|
||||
conversation_id = str(payload.get("external_conversation_id") or "").strip()
|
||||
text = str(payload.get("text") or "").strip()
|
||||
if not external_message_id or not conversation_id or not text:
|
||||
raise ChannelError("消息 ID、会话 ID 和文本不能为空")
|
||||
existing = ChannelMessage.objects.filter(
|
||||
endpoint=endpoint,
|
||||
direction="inbound",
|
||||
external_message_id=external_message_id,
|
||||
).first()
|
||||
if existing is not None:
|
||||
return existing
|
||||
conversation, _ = ChannelConversation.objects.get_or_create(
|
||||
endpoint=endpoint,
|
||||
external_conversation_id=conversation_id,
|
||||
defaults={
|
||||
"title": str(payload.get("title") or "")[:200],
|
||||
"participants": payload.get("participants")
|
||||
if isinstance(payload.get("participants"), list)
|
||||
else [],
|
||||
},
|
||||
)
|
||||
message = ChannelMessage.objects.create(
|
||||
endpoint=endpoint,
|
||||
conversation=conversation,
|
||||
ticket=conversation.ticket,
|
||||
direction="inbound",
|
||||
external_message_id=external_message_id,
|
||||
idempotency_key=external_message_id,
|
||||
sender=str(payload.get("sender") or "")[:160],
|
||||
text=text,
|
||||
payload=payload.get("payload") if isinstance(payload.get("payload"), dict) else {},
|
||||
status="received",
|
||||
occurred_at=payload.get("occurred_at"),
|
||||
)
|
||||
OperationAuditLog.objects.create(
|
||||
module="channels",
|
||||
action="message_received",
|
||||
target_type="channel_message",
|
||||
target_id=str(message.public_id),
|
||||
request_snapshot={
|
||||
"endpoint_id": endpoint.id,
|
||||
"external_message_id": external_message_id,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
result="success",
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
def _validate_outbound_url(value: str) -> None:
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username:
|
||||
raise ChannelError("出站地址必须是无内嵌凭证的 HTTP(S) URL")
|
||||
|
||||
|
||||
def _confirm_delivered_reply(message: ChannelMessage) -> None:
|
||||
if not message.customer_reply_task_id:
|
||||
return
|
||||
try:
|
||||
confirm_customer_reply_sent(
|
||||
message.customer_reply_task,
|
||||
expected_revision=message.reply_revision,
|
||||
actor=message.created_by,
|
||||
source=TicketActorSource.CHANNEL,
|
||||
channel_message_public_id=str(message.public_id),
|
||||
)
|
||||
except CustomerReplyError as error:
|
||||
raise ChannelError(f"渠道已送达,但回复任务回写失败:{error}") from error
|
||||
|
||||
|
||||
def deliver_outbound_message(message_id: int) -> ChannelMessage:
|
||||
"""投递已批准的回复版本,并只在明确送达后完成客户回复任务。"""
|
||||
|
||||
message = ChannelMessage.objects.select_related(
|
||||
"endpoint",
|
||||
"conversation",
|
||||
"customer_reply_task",
|
||||
).get(id=message_id)
|
||||
endpoint = message.endpoint
|
||||
if message.status == "delivered":
|
||||
_confirm_delivered_reply(message)
|
||||
return message
|
||||
if message.status not in {"queued", "failed"}:
|
||||
return message
|
||||
if not endpoint.enabled or not endpoint.outbound_url:
|
||||
message.status = "failed"
|
||||
message.error_message = "渠道未启用或未配置出站地址"
|
||||
message.save(update_fields=["status", "error_message", "updated_at"])
|
||||
return message
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Idempotency-Key": message.idempotency_key,
|
||||
}
|
||||
if endpoint.outbound_token_encrypted:
|
||||
headers["Authorization"] = (
|
||||
f"Bearer {decrypt_secret(endpoint.outbound_token_encrypted)}"
|
||||
)
|
||||
body = json.dumps(
|
||||
{
|
||||
"conversation_id": message.conversation.external_conversation_id,
|
||||
"text": message.text,
|
||||
"message_id": str(message.public_id),
|
||||
"metadata": message.payload,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode()
|
||||
try:
|
||||
_validate_outbound_url(endpoint.outbound_url)
|
||||
request = Request(endpoint.outbound_url, data=body, headers=headers, method="POST")
|
||||
with urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
if not isinstance(result, dict):
|
||||
raise ChannelError("渠道返回结构无效")
|
||||
except (
|
||||
HTTPError,
|
||||
URLError,
|
||||
TimeoutError,
|
||||
json.JSONDecodeError,
|
||||
ChannelError,
|
||||
) as error:
|
||||
message.status = "failed"
|
||||
message.error_message = str(error) or "渠道发送失败"
|
||||
else:
|
||||
message.status = str(result.get("status") or "sent")
|
||||
if message.status not in {"sent", "delivered"}:
|
||||
message.status = "sent"
|
||||
message.external_message_id = str(result.get("external_message_id") or "")
|
||||
message.error_message = ""
|
||||
message.sent_at = timezone.now()
|
||||
if message.status == "delivered":
|
||||
message.delivered_at = timezone.now()
|
||||
message.save()
|
||||
if message.status == "delivered":
|
||||
_confirm_delivered_reply(message)
|
||||
OperationAuditLog.objects.create(
|
||||
user=message.created_by,
|
||||
module="channels",
|
||||
action="message_delivery_finished",
|
||||
target_type="channel_message",
|
||||
target_id=str(message.public_id),
|
||||
request_snapshot={
|
||||
"endpoint_id": endpoint.id,
|
||||
"status": message.status,
|
||||
"idempotency_key": message.idempotency_key,
|
||||
},
|
||||
result="success" if message.status in {"sent", "delivered"} else "failed",
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def queue_outbound_message(
|
||||
endpoint: ChannelEndpoint,
|
||||
payload: dict,
|
||||
*,
|
||||
actor,
|
||||
) -> ChannelMessage:
|
||||
"""冻结客户回复版本并生成唯一出站消息,提交后再投递异步任务。"""
|
||||
|
||||
endpoint = ChannelEndpoint.objects.select_for_update().get(id=endpoint.id)
|
||||
if not endpoint.enabled:
|
||||
raise ChannelError("渠道已停用")
|
||||
idempotency_key = str(payload.get("idempotency_key") or "").strip()
|
||||
conversation_id = str(payload.get("external_conversation_id") or "").strip()
|
||||
if not idempotency_key or not conversation_id:
|
||||
raise ChannelError("幂等键和会话 ID 不能为空")
|
||||
try:
|
||||
reply_task_id = int(payload.get("customer_reply_task_id"))
|
||||
expected_revision = int(payload.get("expected_revision"))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ChannelError("客户回复任务和版本不能为空") from error
|
||||
existing = (
|
||||
ChannelMessage.objects.select_related("conversation")
|
||||
.filter(
|
||||
endpoint=endpoint,
|
||||
direction="outbound",
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
if (
|
||||
existing.customer_reply_task_id == reply_task_id
|
||||
and existing.reply_revision == expected_revision
|
||||
and existing.conversation
|
||||
and existing.conversation.external_conversation_id == conversation_id
|
||||
):
|
||||
return existing
|
||||
raise ChannelError("幂等键已用于其他客户回复")
|
||||
reply_task = (
|
||||
CustomerReplyTask.objects.select_related("ticket", "support_analysis")
|
||||
.filter(id=reply_task_id)
|
||||
.first()
|
||||
)
|
||||
if reply_task is None:
|
||||
raise ChannelError("客户回复任务不存在")
|
||||
try:
|
||||
reply_task = approve_customer_reply_for_channel(
|
||||
reply_task,
|
||||
expected_revision=expected_revision,
|
||||
actor=actor,
|
||||
channel_endpoint_id=endpoint.id,
|
||||
external_conversation_id=conversation_id,
|
||||
)
|
||||
except CustomerReplyError as error:
|
||||
raise ChannelError(str(error)) from error
|
||||
existing_reply = (
|
||||
ChannelMessage.objects.select_related("conversation")
|
||||
.filter(
|
||||
customer_reply_task=reply_task,
|
||||
reply_revision=reply_task.revision,
|
||||
direction="outbound",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing_reply is not None:
|
||||
if (
|
||||
existing_reply.endpoint_id == endpoint.id
|
||||
and existing_reply.conversation
|
||||
and existing_reply.conversation.external_conversation_id == conversation_id
|
||||
):
|
||||
return existing_reply
|
||||
raise ChannelError("该客户回复版本已进入其他渠道会话")
|
||||
conversation, _ = ChannelConversation.objects.get_or_create(
|
||||
endpoint=endpoint,
|
||||
external_conversation_id=conversation_id,
|
||||
)
|
||||
if conversation.ticket_id and conversation.ticket_id != reply_task.ticket_id:
|
||||
raise ChannelError("渠道会话已关联其他工单")
|
||||
if not conversation.ticket_id:
|
||||
conversation.ticket = reply_task.ticket
|
||||
conversation.save(update_fields=["ticket", "updated_at"])
|
||||
metadata = payload.get("payload") if isinstance(payload.get("payload"), dict) else {}
|
||||
metadata = {
|
||||
**metadata,
|
||||
"customer_reply_task_id": reply_task.id,
|
||||
"reply_revision": reply_task.revision,
|
||||
}
|
||||
message = ChannelMessage.objects.create(
|
||||
endpoint=endpoint,
|
||||
conversation=conversation,
|
||||
ticket=reply_task.ticket,
|
||||
customer_reply_task=reply_task,
|
||||
reply_revision=reply_task.revision,
|
||||
direction="outbound",
|
||||
idempotency_key=idempotency_key,
|
||||
sender=actor.get_full_name() or actor.username,
|
||||
text=reply_task.final_text,
|
||||
payload=metadata,
|
||||
status="queued",
|
||||
created_by=actor,
|
||||
)
|
||||
OperationAuditLog.objects.create(
|
||||
user=actor,
|
||||
module="channels",
|
||||
action="customer_reply_queued",
|
||||
target_type="channel_message",
|
||||
target_id=str(message.public_id),
|
||||
request_snapshot={
|
||||
"endpoint_id": endpoint.id,
|
||||
"ticket_id": reply_task.ticket_id,
|
||||
"customer_reply_task_id": reply_task.id,
|
||||
"reply_revision": reply_task.revision,
|
||||
"idempotency_key": idempotency_key,
|
||||
},
|
||||
result="success",
|
||||
)
|
||||
|
||||
def enqueue_delivery():
|
||||
from apps.channels.tasks import deliver_channel_message_task
|
||||
|
||||
deliver_channel_message_task.delay(message.id)
|
||||
|
||||
transaction.on_commit(enqueue_delivery)
|
||||
return message
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def record_channel_receipt(endpoint: ChannelEndpoint, payload: dict) -> ChannelReceipt:
|
||||
"""幂等记录渠道回执并推进消息状态,重复回执不重复完成业务任务。"""
|
||||
|
||||
endpoint = ChannelEndpoint.objects.select_for_update().get(id=endpoint.id)
|
||||
receipt_type = str(payload.get("receipt_type") or "").strip()
|
||||
external_message_id = str(payload.get("external_message_id") or "").strip()
|
||||
external_receipt_id = str(payload.get("external_receipt_id") or "").strip()
|
||||
if receipt_type not in {"sent", "delivered", "read", "failed"}:
|
||||
raise ChannelError("未知回执类型")
|
||||
if not external_message_id or not external_receipt_id:
|
||||
raise ChannelError("消息 ID 和回执 ID 不能为空")
|
||||
message = ChannelMessage.objects.select_for_update().filter(
|
||||
endpoint=endpoint,
|
||||
direction="outbound",
|
||||
external_message_id=external_message_id,
|
||||
).first()
|
||||
if message is None:
|
||||
raise ChannelError("回执对应的出站消息不存在")
|
||||
receipt, created = ChannelReceipt.objects.get_or_create(
|
||||
message=message,
|
||||
receipt_type=receipt_type,
|
||||
external_receipt_id=external_receipt_id,
|
||||
defaults={
|
||||
"payload": payload.get("payload")
|
||||
if isinstance(payload.get("payload"), dict)
|
||||
else {}
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
return receipt
|
||||
if receipt_type == "failed":
|
||||
message.status = "failed"
|
||||
message.error_message = str(receipt.payload.get("reason") or "渠道返回失败回执")
|
||||
elif receipt_type in {"delivered", "read"}:
|
||||
message.status = "delivered"
|
||||
message.delivered_at = timezone.now()
|
||||
message.error_message = ""
|
||||
elif message.status == "queued":
|
||||
message.status = "sent"
|
||||
message.sent_at = timezone.now()
|
||||
message.save()
|
||||
if message.status == "delivered":
|
||||
_confirm_delivered_reply(message)
|
||||
return receipt
|
||||
8
backend/apps/channels/tasks.py
Normal file
8
backend/apps/channels/tasks.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from celery import shared_task
|
||||
|
||||
from apps.channels.services import deliver_outbound_message
|
||||
|
||||
|
||||
@shared_task(name="apps.channels.tasks.deliver_channel_message")
|
||||
def deliver_channel_message_task(message_id: int):
|
||||
return deliver_outbound_message(message_id).status
|
||||
1
backend/apps/channels/tests/__init__.py
Normal file
1
backend/apps/channels/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
286
backend/apps/channels/tests/test_channel_runtime.py
Normal file
286
backend/apps/channels/tests/test_channel_runtime.py
Normal file
@@ -0,0 +1,286 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.channels.models import ChannelEndpoint, ChannelMessage, ChannelReceipt
|
||||
from apps.channels.services import (
|
||||
ChannelError,
|
||||
queue_outbound_message,
|
||||
record_channel_receipt,
|
||||
)
|
||||
from apps.modelhub.crypto import encrypt_secret
|
||||
from apps.tickets.models import CustomerReplyTask, Ticket
|
||||
from apps.workitems.models import WorkItem
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: dict):
|
||||
self.payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps(self.payload).encode()
|
||||
|
||||
|
||||
def signature(secret: str, body: bytes) -> str:
|
||||
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
return f"sha256={digest}"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_signed_inbound_webhook_is_idempotent(client):
|
||||
secret = "channel-inbound-secret"
|
||||
endpoint = ChannelEndpoint.objects.create(
|
||||
name="客服群",
|
||||
webhook_secret_encrypted=encrypt_secret(secret),
|
||||
)
|
||||
body = json.dumps(
|
||||
{
|
||||
"external_message_id": "msg-in-001",
|
||||
"external_conversation_id": "group-001",
|
||||
"text": "生产环境登录失败,错误码 AUTH-401",
|
||||
"sender": "客户甲",
|
||||
"participants": ["客户甲", "技术支持"],
|
||||
"payload": {"source": "group"},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
headers = {"HTTP_X_CHANNEL_SIGNATURE": signature(secret, body)}
|
||||
|
||||
first = client.post(
|
||||
f"/api/channels/webhooks/{endpoint.public_id}/messages",
|
||||
data=body,
|
||||
content_type="application/json",
|
||||
**headers,
|
||||
)
|
||||
second = client.post(
|
||||
f"/api/channels/webhooks/{endpoint.public_id}/messages",
|
||||
data=body,
|
||||
content_type="application/json",
|
||||
**headers,
|
||||
)
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert first.json()["public_id"] == second.json()["public_id"]
|
||||
assert ChannelMessage.objects.count() == 1
|
||||
message = ChannelMessage.objects.get()
|
||||
assert message.status == "received"
|
||||
assert message.conversation.external_conversation_id == "group-001"
|
||||
assert OperationAuditLog.objects.filter(
|
||||
module="channels",
|
||||
action="message_received",
|
||||
target_id=str(message.public_id),
|
||||
).count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_inbound_webhook_rejects_invalid_signature(client):
|
||||
endpoint = ChannelEndpoint.objects.create(
|
||||
name="签名渠道",
|
||||
webhook_secret_encrypted=encrypt_secret("correct-secret"),
|
||||
)
|
||||
response = client.post(
|
||||
f"/api/channels/webhooks/{endpoint.public_id}/messages",
|
||||
data={
|
||||
"external_message_id": "msg-invalid",
|
||||
"external_conversation_id": "group-invalid",
|
||||
"text": "不应被接收",
|
||||
},
|
||||
content_type="application/json",
|
||||
HTTP_X_CHANNEL_SIGNATURE="sha256=invalid",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert ChannelMessage.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_outbound_delivery_and_receipt_are_idempotent(
|
||||
admin_client,
|
||||
django_capture_on_commit_callbacks,
|
||||
monkeypatch,
|
||||
):
|
||||
actor = get_user_model().objects.filter(is_superuser=True).first()
|
||||
assert actor is not None
|
||||
endpoint = ChannelEndpoint.objects.create(
|
||||
name="出站渠道",
|
||||
outbound_url="https://channel.example/messages",
|
||||
webhook_secret_encrypted=encrypt_secret("receipt-secret"),
|
||||
outbound_token_encrypted=encrypt_secret("outbound-token"),
|
||||
)
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no="TK-CHANNEL-0001",
|
||||
title="生产环境登录失败",
|
||||
description="生产环境登录失败,错误码 AUTH-401",
|
||||
status="pending",
|
||||
)
|
||||
reply_task = CustomerReplyTask.objects.create(
|
||||
ticket=ticket,
|
||||
status="draft",
|
||||
draft_text="已收到问题,正在处理。",
|
||||
final_text="已收到问题,正在处理。",
|
||||
created_by=actor,
|
||||
)
|
||||
requests = []
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
requests.append((request, timeout))
|
||||
return FakeResponse(
|
||||
{
|
||||
"status": "sent",
|
||||
"external_message_id": "external-out-001",
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("apps.channels.services.urlopen", fake_urlopen)
|
||||
payload = {
|
||||
"external_conversation_id": "group-002",
|
||||
"idempotency_key": "reply-001",
|
||||
"customer_reply_task_id": reply_task.id,
|
||||
"expected_revision": reply_task.revision,
|
||||
"payload": {"template": "ack"},
|
||||
}
|
||||
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
first = queue_outbound_message(endpoint, payload, actor=actor)
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
second = queue_outbound_message(endpoint, payload, actor=actor)
|
||||
|
||||
first.refresh_from_db()
|
||||
reply_task.refresh_from_db()
|
||||
ticket.refresh_from_db()
|
||||
assert first.id == second.id
|
||||
assert first.status == "sent"
|
||||
assert first.external_message_id == "external-out-001"
|
||||
assert first.customer_reply_task == reply_task
|
||||
assert first.reply_revision == 1
|
||||
assert first.text == reply_task.final_text
|
||||
assert reply_task.status == "approved"
|
||||
assert reply_task.approved_by == actor
|
||||
assert reply_task.approved_at is not None
|
||||
assert ticket.replied_to_customer is False
|
||||
assert ChannelMessage.objects.filter(direction="outbound").count() == 1
|
||||
assert len(requests) == 1
|
||||
request, timeout = requests[0]
|
||||
assert timeout == 30
|
||||
assert request.get_header("Idempotency-key") == "reply-001"
|
||||
assert request.get_header("Authorization") == "Bearer outbound-token"
|
||||
|
||||
receipt_payload = {
|
||||
"external_message_id": "external-out-001",
|
||||
"external_receipt_id": "receipt-001",
|
||||
"receipt_type": "delivered",
|
||||
"payload": {"provider_status": "delivered"},
|
||||
}
|
||||
first_receipt = record_channel_receipt(endpoint, receipt_payload)
|
||||
second_receipt = record_channel_receipt(endpoint, receipt_payload)
|
||||
|
||||
first.refresh_from_db()
|
||||
reply_task.refresh_from_db()
|
||||
ticket.refresh_from_db()
|
||||
assert first_receipt.id == second_receipt.id
|
||||
assert first.status == "delivered"
|
||||
assert first.delivered_at is not None
|
||||
assert reply_task.status == "sent"
|
||||
assert reply_task.sent_at is not None
|
||||
assert ticket.replied_to_customer is True
|
||||
assert WorkItem.objects.filter(
|
||||
ticket=ticket,
|
||||
task_type="ticket_customer_reply",
|
||||
status="completed",
|
||||
).count() == 1
|
||||
assert ChannelReceipt.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_customer_reply_version_cannot_be_queued_to_another_conversation(admin_client):
|
||||
actor = get_user_model().objects.get(is_superuser=True)
|
||||
endpoint = ChannelEndpoint.objects.create(
|
||||
name="唯一回复渠道",
|
||||
outbound_url="https://channel.example/messages",
|
||||
)
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no="TK-CHANNEL-0002",
|
||||
title="接口超时",
|
||||
description="生产接口持续超时",
|
||||
status="pending",
|
||||
)
|
||||
reply_task = CustomerReplyTask.objects.create(
|
||||
ticket=ticket,
|
||||
draft_text="已定位到上游依赖超时。",
|
||||
final_text="已定位到上游依赖超时。",
|
||||
created_by=actor,
|
||||
)
|
||||
first_payload = {
|
||||
"external_conversation_id": "group-a",
|
||||
"idempotency_key": "reply-version-a",
|
||||
"customer_reply_task_id": reply_task.id,
|
||||
"expected_revision": 1,
|
||||
}
|
||||
|
||||
queue_outbound_message(endpoint, first_payload, actor=actor)
|
||||
|
||||
with pytest.raises(ChannelError, match="已进入其他渠道会话"):
|
||||
queue_outbound_message(
|
||||
endpoint,
|
||||
{
|
||||
**first_payload,
|
||||
"external_conversation_id": "group-b",
|
||||
"idempotency_key": "reply-version-b",
|
||||
},
|
||||
actor=actor,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_outbound_api_rejects_arbitrary_text(admin_client):
|
||||
endpoint = ChannelEndpoint.objects.create(
|
||||
name="受控发送渠道",
|
||||
outbound_url="https://channel.example/messages",
|
||||
)
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/channels/endpoints/{endpoint.id}/messages",
|
||||
{
|
||||
"external_conversation_id": "group-uncontrolled",
|
||||
"idempotency_key": "uncontrolled-001",
|
||||
"ticket_id": 1,
|
||||
"text": "绕过客户回复任务直接发送",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert ChannelMessage.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_channel_management_api_hides_credentials(admin_client):
|
||||
response = admin_client.post(
|
||||
"/api/channels/endpoints",
|
||||
{
|
||||
"name": "治理渠道",
|
||||
"outbound_url": "https://channel.example/send",
|
||||
"webhook_secret": "webhook-secret",
|
||||
"outbound_token": "outbound-token",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["webhook_secret_set"] is True
|
||||
assert payload["outbound_token_set"] is True
|
||||
assert "webhook_secret" not in payload
|
||||
assert "outbound_token" not in payload
|
||||
@@ -1,7 +1,10 @@
|
||||
import os
|
||||
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"))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user