ForcePilot/backend/package/yuxi/channels/adapters/service_account_adapter.py

565 lines
17 KiB
Python
Raw Normal View History

"""ServiceAccountAdapter实现 ServiceAccountPort 与 ServiceAccountRepositoryPort。
- 同时实现领域服务端口幂等 ensure与仓储端口create/get
- 复用模块级 pg_managertx 非空时加入应用层事务共享 SqlAlchemyTransactionContext session
- 错误翻译SQLAlchemyError ServiceAccountCreationError创建/ DependencyError查询
- 事务边界tx 非空时使用 tx 关联的 session不得自主提交tx None 时通过 pg_manager 获取 session单方法提交
依赖边界只依赖 yuxi.channels.contract端口 + DTO + 错误
yuxi.channels.core.modelServiceAccount 聚合根
yuxi.storage.postgresORM 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