ForcePilot/backend/package/yuxi/channel/security/identity.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

129 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""多渠道网关跨渠道身份关联。"""
from __future__ import annotations
import asyncio
from yuxi.repositories.channel_identity_repository import ChannelIdentityRepository
from yuxi.storage.postgres.manager import pg_manager
class IdentityLinkResolver:
def __init__(
self,
identity_links: dict[str, list[str]] | None = None,
identity_repository: ChannelIdentityRepository | None = None,
):
# { "canonical_name": ["feishu:ou_xxx", "dingtalk:12345", "telegram:67890"] }
self._links = identity_links or {}
self._reverse_index: dict[str, str] = {}
self._repository = identity_repository
self._lock = asyncio.Lock()
for canonical, ids in self._links.items():
for linked_id in ids:
self._reverse_index[linked_id] = canonical
@classmethod
async def create(cls, identity_repository: ChannelIdentityRepository | None = None) -> IdentityLinkResolver:
"""从数据库加载全量绑定关系并构建内存索引。
若未提供 repository则使用 pg_manager 新建会话加载;
运行期 link/unlink 会复用初始化时传入的 repository否则每次新建会话。
"""
if identity_repository is not None:
records = await identity_repository.load_all()
return cls._from_records(records, identity_repository)
async with pg_manager.get_async_session_context() as db:
repo = ChannelIdentityRepository(db)
records = await repo.load_all()
return cls._from_records(records, repo)
@classmethod
def _from_records(
cls,
records: list,
identity_repository: ChannelIdentityRepository | None,
) -> IdentityLinkResolver:
links: dict[str, list[str]] = {}
for record in records:
canonical = record.platform_user_id
linked_id = f"{record.channel_type}:{record.channel_sender_id}"
links.setdefault(canonical, []).append(linked_id)
return cls(identity_links=links, identity_repository=identity_repository)
def resolve(self, channel_type: str, peer_id: str) -> str:
key = f"{channel_type}:{peer_id}"
if key in self._reverse_index:
return self._reverse_index[key]
if peer_id in self._reverse_index:
return self._reverse_index[peer_id]
return peer_id
async def link(
self,
channel_type: str,
channel_sender_id: str,
platform_user_id: str,
account_id: str,
paired_by: str = "qr",
) -> None:
linked_id = f"{channel_type}:{channel_sender_id}"
async with self._lock:
if self._repository is not None:
await self._repository.create(
channel_type=channel_type,
account_id=account_id,
channel_sender_id=channel_sender_id,
platform_user_id=platform_user_id,
paired_by=paired_by,
)
else:
async with pg_manager.get_async_session_context() as db:
repo = ChannelIdentityRepository(db)
await repo.create(
channel_type=channel_type,
account_id=account_id,
channel_sender_id=channel_sender_id,
platform_user_id=platform_user_id,
paired_by=paired_by,
)
self._links.setdefault(platform_user_id, []).append(linked_id)
self._reverse_index[linked_id] = platform_user_id
async def unlink(
self,
channel_type: str,
account_id: str,
channel_sender_id: str,
) -> bool:
linked_id = f"{channel_type}:{channel_sender_id}"
async with self._lock:
if self._repository is not None:
deleted = await self._repository.delete_by_sender(
channel_type=channel_type,
account_id=account_id,
channel_sender_id=channel_sender_id,
)
else:
async with pg_manager.get_async_session_context() as db:
repo = ChannelIdentityRepository(db)
deleted = await repo.delete_by_sender(
channel_type=channel_type,
account_id=account_id,
channel_sender_id=channel_sender_id,
)
if deleted:
self._reverse_index.pop(linked_id, None)
for canonical, ids in list(self._links.items()):
if linked_id in ids:
ids.remove(linked_id)
if not ids:
self._links.pop(canonical, None)
break
return deleted