本次提交包含多维度代码优化与功能增强: 1. 移除报告模块冗余导入与枚举,清理报表相关代码 2. 新增扫码登录支持方法与飞书适配器适配 3. 完善异常日志与健康检查信息 4. 扩展目录、配对管理、能力查询等接口 5. 优化出站管道与事务提交后钩子逻辑 6. 修复飞书消息解析与响应空值问题 7. 重构配置更新与服务账号创建逻辑 8. 统一传输错误分类契约与错误基类扩展
565 lines
17 KiB
Python
565 lines
17 KiB
Python
"""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
|