"""ServiceAccountAdapter:实现 ServiceAccountPort 与 ServiceAccountRepositoryPort。 - 同时实现领域服务端口(幂等 ensure)与仓储端口(create/get) - 复用模块级 pg_manager,tx 非空时加入应用层事务(共享 SqlAlchemyTransactionContext 的 session) - 错误翻译:SQLAlchemyError → ServiceAccountCreationError(创建)/ DependencyError(查询) - 事务边界:tx 非空时使用 tx 关联的 session,不得自主提交;tx 为 None 时通过 pg_manager 获取 session(单方法提交) 依赖边界:只依赖 yuxi.channels.contract(端口 + DTO + 错误)、 yuxi.channels.core.model(ServiceAccount 聚合根)、 yuxi.storage.postgres(ORM Model + pg_manager)、 yuxi.utils.auth_utils(密码哈希)、sqlalchemy。 """ from __future__ import annotations import secrets from typing import TYPE_CHECKING, Any from sqlalchemy import select from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from yuxi.channels.contract.dtos.channel import ChannelType from yuxi.channels.contract.dtos.service_account import ( CreateServiceAccountCmd, ServiceAccount, ) from yuxi.channels.contract.errors import ( DependencyError, ServiceAccountCreationError, ValidationError, ) from yuxi.channels.contract.errors.base import Error from yuxi.channels.contract.ports.driven.logger_port import LoggerPort from yuxi.channels.contract.ports.driven.service_account_port import ( ServiceAccountPort, ) from yuxi.channels.contract.ports.driven.service_account_repository_port import ( ServiceAccountRepositoryPort, ) from yuxi.storage.postgres.manager import pg_manager from yuxi.storage.postgres.models_business import User as UserORM from yuxi.storage.postgres.models_channels import ChannelAccount as ChannelAccountORM from yuxi.utils.auth_utils import AuthUtils if TYPE_CHECKING: from yuxi.channels.contract.ports.driven.transaction_port import ( TransactionContext, ) __all__ = ["ServiceAccountAdapter"] # 服务账号 uid 前缀,与 ServiceAccount 聚合根 create() 工厂方法保持一致 _SERVICE_ACCOUNT_UID_PREFIX = "svc:channel:" class ServiceAccountAdapter(ServiceAccountPort, ServiceAccountRepositoryPort): """服务账号被驱动适配器实现。 同时实现 ``ServiceAccountPort``(领域服务编排:幂等 ensure)与 ``ServiceAccountRepositoryPort``(持久化原语:create/get)。 仅注入 ``LoggerPort``,``pg_manager`` 通过模块级导入使用。事务边界: - ``tx`` 非空时使用 ``tx`` 关联的 session(``SqlAlchemyTransactionContext._session``), 适配器 **不得** 自主提交,由应用层统一提交。 - ``tx`` 为 ``None`` 时通过 ``pg_manager.get_async_session_context()`` 获取 session, 上下文退出时自动提交(向后兼容)。 约束: - INV-8 适配器无业务规则:仅做协议转换与持久化操作。 - 复用现有 pg_manager,不引入新连接池(INV-I3)。 - 异常翻译:``SQLAlchemyError`` → ``ServiceAccountCreationError``(创建)/ ``DependencyError``(查询),``ServiceAccountCreationError`` 直接放行。 """ def __init__(self, logger: LoggerPort) -> None: """初始化适配器,注入日志端口。 Args: logger: 日志被驱动端口,用于记录创建与查询过程中的故障。 """ self._logger = logger def _resolve_session(self, tx: TransactionContext | None) -> AsyncSession: """从事务上下文中解析共享 session。 适配器层耦合 ``SqlAlchemyTransactionContext`` 实现:访问其私有的 ``_session`` 以加入应用层事务。事务边界由应用层控制,适配器不得 自主提交。 参数: tx: 事务上下文,``None`` 表示无应用层事务(调用方应走 pg_manager 路径)。 返回: 事务上下文关联的 ``AsyncSession``。 抛出: DependencyError: ``tx`` 非空但无法获取关联 session。 """ session = getattr(tx, "_session", None) if tx is not None else None if session is None: raise DependencyError( "service_account", Error("transaction context has no session"), ) return session @staticmethod def _parse_uid(uid: str) -> tuple[ChannelType, str]: """从服务账号 uid 解析渠道类型与账户 ID。 uid 格式由 ``ServiceAccountAggregate.create()`` 定义: ``svc:channel:{channel_type_value}:{account_id}``。 参数: uid: 服务账号唯一标识。 返回: (channel_type, account_id) 元组。 抛出: ValidationError: uid 不符合服务账号格式(实现 UnifiedError 协议)。 """ if not uid.startswith(_SERVICE_ACCOUNT_UID_PREFIX): raise ValidationError( field="uid", message=f"uid does not follow service account format: {uid}", ) rest = uid[len(_SERVICE_ACCOUNT_UID_PREFIX) :] parts = rest.split(":", 1) if len(parts) != 2 or not parts[0] or not parts[1]: raise ValidationError( field="uid", message=f"uid missing channel_type or account_id: {uid}", ) return ChannelType(parts[0]), parts[1] @staticmethod def _to_dto( orm: UserORM, channel_type: ChannelType, account_id: str, ) -> ServiceAccount: """User ORM → ServiceAccount DTO 转换。 参数: orm: User ORM 记录(``user_type='service'``)。 channel_type: 渠道类型(来自查询参数或 uid 解析,非 ORM 字段)。 account_id: 渠道账户 ID(来自查询参数或 uid 解析,非 ORM 字段)。 返回: ServiceAccount DTO。 """ return ServiceAccount( uid=orm.uid, username=orm.username, channel_type=channel_type, account_id=account_id, department_id=orm.department_id, ) async def _create_user( self, session: AsyncSession, data: dict[str, Any], *, commit: bool, ) -> UserORM: """在指定 session 上创建 User 记录。 参数: session: SQLAlchemy 异步会话。 data: User 字段字典。 commit: 是否由本方法提交事务(``tx`` 为 ``None`` 时 ``True``,非空时 ``False``)。 返回: 已刷新的 User ORM 记录(含服务端默认字段)。 """ orm = UserORM(**data) session.add(orm) await session.flush() if commit: await session.commit() await session.refresh(orm) return orm async def ensureServiceAccount( self, cmd: CreateServiceAccountCmd, *, tx: TransactionContext | None = None, ) -> ServiceAccount: """幂等创建服务账号。 先通过 ``getServiceAccountByChannelAccount`` 查询,已存在则返回; 不存在则通过 ``uid`` 二次预检,避免在共享事务内触发 ``IntegrityError`` (一旦触发,SQLAlchemy 会标记当前事务为 aborted,后续 ``saveChannelAccount`` 将不可用)。仅当确实不存在时才调用 ``createServiceAccount`` 创建。 遵循事务边界:``tx`` 非空时加入应用层事务,``None`` 时单方法提交。 """ existing = await self.getServiceAccountByChannelAccount(cmd.channel_type, cmd.account_id) if existing is not None: return existing # 前置幂等检查:按 uid 查询是否已有残留服务账号(例如前一次创建服务账号后 # channel_accounts 写入失败/被软删的场景)。在共享事务外查询,避免尝试插入 # 已存在的 uid/username 导致事务被标记为 aborted。 existing_by_uid = await self.getServiceAccountByUid(cmd.uid) if existing_by_uid is not None: await self._logger.info( "service account already exists by uid, returning existing", channel_type=cmd.channel_type, account_id=cmd.account_id, uid=cmd.uid, ) return existing_by_uid return await self.createServiceAccount(cmd, tx=tx) async def createServiceAccount( self, cmd: CreateServiceAccountCmd, *, tx: TransactionContext | None = None, ) -> ServiceAccount: """创建服务账号记录(纯持久化操作)。 uid/username 已由应用层调 ``ServiceAccount.create()`` 聚合根工厂生成 并通过 ``cmd`` 传入(§4.6 / INV-8,适配器不得 import core.model)。 创建 User ORM 记录(``user_type='service'``、``role='user'``),密码为随机 字符串的 argon2 哈希(服务账号不可登录)。创建失败抛 ``ServiceAccountCreationError``。 """ random_password = secrets.token_urlsafe(32) user_data: dict[str, Any] = { "uid": cmd.uid, "username": cmd.username, "password_hash": AuthUtils.hash_password(random_password), "role": "user", "user_type": "service", "department_id": cmd.department_id, } try: if tx is not None: session = self._resolve_session(tx) orm = await self._create_user(session, user_data, commit=False) else: async with pg_manager.get_async_session_context() as session: orm = await self._create_user(session, user_data, commit=True) except ServiceAccountCreationError: raise except IntegrityError as exc: # 幂等性修复:IntegrityError 时可能是重试场景,User 记录已存在但 # channel_accounts.service_user_uid 尚未设置。通过 uid 重新查询。 existing = await self.getServiceAccountByUid(cmd.uid) if existing is not None: await self._logger.info( "service account already exists, returning existing", channel_type=cmd.channel_type, account_id=cmd.account_id, uid=cmd.uid, ) return existing await self._logger.error( "service account creation failed due to integrity error", channel_type=cmd.channel_type, account_id=cmd.account_id, error=str(exc), ) raise ServiceAccountCreationError( channel_type=cmd.channel_type, account_id=cmd.account_id, reason=f"integrity constraint violated: {exc}", cause=exc, ) from exc except SQLAlchemyError as exc: raise ServiceAccountCreationError( channel_type=cmd.channel_type, account_id=cmd.account_id, reason=f"database error: {exc}", cause=exc, ) from exc except DependencyError: raise except Exception as exc: raise ServiceAccountCreationError( channel_type=cmd.channel_type, account_id=cmd.account_id, reason=f"unexpected error: {exc}", cause=exc, ) from exc await self._logger.info( "service account created", channel_type=cmd.channel_type, account_id=cmd.account_id, uid=cmd.uid, ) return self._to_dto(orm, cmd.channel_type, cmd.account_id) async def getServiceAccountByChannelAccount( self, channel_type: ChannelType, account_id: str, ) -> ServiceAccount | None: """按渠道账户查询服务账号。 通过 ``channel_accounts.service_user_uid`` JOIN ``User`` 表查询, 过滤 ``user_type='service'`` 且 ``is_deleted=0``。不存在返回 ``None``。 """ try: async with pg_manager.get_async_session_context() as session: stmt = ( select(UserORM) .join( ChannelAccountORM, ChannelAccountORM.service_user_uid == UserORM.uid, ) .where( ChannelAccountORM.channel_type == channel_type, ChannelAccountORM.account_id == account_id, ChannelAccountORM.is_deleted == 0, UserORM.user_type == "service", UserORM.is_deleted == 0, ) ) result = await session.execute(stmt) orm = result.scalar_one_or_none() if orm is None: return None return self._to_dto(orm, channel_type, account_id) except SQLAlchemyError as exc: raise DependencyError("service_account", Error(str(exc))) from exc async def getServiceAccountByUid(self, uid: str) -> ServiceAccount | None: """按 uid 查询服务账号。 直接查 ``User`` 表,过滤 ``user_type='service'`` 且 ``is_deleted=0``。 不存在返回 ``None``。渠道类型与账户 ID 从 uid 解析得出(格式由 ``ServiceAccountAggregate.create()`` 定义)。 """ try: async with pg_manager.get_async_session_context() as session: stmt = select(UserORM).where( UserORM.uid == uid, UserORM.user_type == "service", UserORM.is_deleted == 0, ) result = await session.execute(stmt) orm = result.scalar_one_or_none() if orm is None: return None channel_type, account_id = self._parse_uid(orm.uid) return self._to_dto(orm, channel_type, account_id) except SQLAlchemyError as exc: raise DependencyError("service_account", Error(str(exc))) from exc except (ValueError, KeyError) as exc: raise DependencyError( "service_account", Error(f"invalid service account uid: {exc}"), ) from exc async def getUserDepartmentByUid(self, uid: str) -> int | None: """按 uid 查询用户的部门 ID。 供账户创建场景从操作者 uid 解析 ``department_id``,作为服务账号 ``CreateServiceAccountCmd.department_id`` 的来源。操作者可能是任何 类型的非服务账号用户(普通用户或管理员),仅过滤 ``is_deleted=0``。 不存在时返回 ``None``(由调用方决定兜底语义)。 参数: uid: 系统用户 UID(``Operator.user_id``)。 """ try: async with pg_manager.get_async_session_context() as session: stmt = select(UserORM.department_id).where( UserORM.uid == uid, UserORM.is_deleted == 0, ) result = await session.execute(stmt) return result.scalar_one_or_none() except SQLAlchemyError as exc: raise DependencyError("service_account", Error(str(exc))) from exc async def getUserIdByUid(self, uid: str) -> int | None: """按 uid 查询系统用户的 id(整型主键)。 供 P3 渐进式绑定场景从 ``User.uid`` 字符串解析 ``User.id`` 整型主键, 作为 ``channel_user_identities.user_id`` 列的写入值。操作者可能是 任何类型的非服务账号用户(普通用户或管理员),仅过滤 ``is_deleted=0``。 不存在时返回 ``None``(由调用方决定兜底语义)。 参数: uid: 系统用户 UID(``User.uid`` 字符串)。 返回: ``User.id``(整型主键);不存在返回 ``None``。 """ try: async with pg_manager.get_async_session_context() as session: stmt = select(UserORM.id).where( UserORM.uid == uid, UserORM.is_deleted == 0, ) result = await session.execute(stmt) return result.scalar_one_or_none() except SQLAlchemyError as exc: raise DependencyError("service_account", Error(str(exc))) from exc