ForcePilot/backend/package/yuxi/channels/adapters/service_account_adapter.py
Kris 742299cb07 chore: 批量清理报告相关代码并完成多项功能迭代
本次提交包含多维度代码优化与功能增强:
1.  移除报告模块冗余导入与枚举,清理报表相关代码
2.  新增扫码登录支持方法与飞书适配器适配
3.  完善异常日志与健康检查信息
4.  扩展目录、配对管理、能力查询等接口
5.  优化出站管道与事务提交后钩子逻辑
6.  修复飞书消息解析与响应空值问题
7.  重构配置更新与服务账号创建逻辑
8.  统一传输错误分类契约与错误基类扩展
2026-07-06 20:49:35 +08:00

565 lines
17 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.

"""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