217 lines
9.2 KiB
Python
217 lines
9.2 KiB
Python
"""SQLAlchemy-backed global business → agent / chain mapping store.
|
|
|
|
Global / shared (no ``user_id`` scoping): every account reads and writes the same
|
|
rows. Fixed business rows are seeded once on startup (``ensure_seed_defaults``)
|
|
with empty agent/chain; users fill those in from the management page via
|
|
``update_mapping``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from deerflow.persistence.business_mapping.model import BusinessMappingRow
|
|
|
|
_UNSET = object()
|
|
|
|
# Fixed business rows (业务码 → 展示名). Seeded on first boot; agent/chain left empty.
|
|
# 8BF 页面尚未建好,先占位保留行。顺序即管理表的展示顺序。
|
|
# AFX = goPath=agentfx 深链(新系统 iframe 嵌入的智能体问答,任务详情取本系统 cop-task-detail)。
|
|
# b5-1 = 外部写入本系统详情后,由本系统读取的任务分析单智能体深链。
|
|
# 3q-0…3q-6 / 6-1…6-4 / 7-1…7-4 = 独立配置位(与 3Q/6BF/7BF 等无关),固定排在表尾。
|
|
# 3q-1…3q-6 默认按「自定义类型」落库(goPath 走自定义深链);配置管理页默认不展示这 6 行。
|
|
_CUSTOM_SLOT = {"is_custom": True, "mapping_type": "自定义类型"}
|
|
DEFAULT_BUSINESS_ROWS: list[dict[str, Any]] = [
|
|
{"business_code": "3Q", "label": "3QFX"},
|
|
{"business_code": "6BF", "label": "任务FX"},
|
|
{"business_code": "7BF", "label": "XDFX"},
|
|
{"business_code": "8BF", "label": "8BF"},
|
|
{"business_code": "AFX", "label": "AGENTFX"},
|
|
{"business_code": "b5-1", "label": "任务分析"},
|
|
# 独立配置位,与上方业务码无关;固定排在表尾。
|
|
{"business_code": "3q-0", "label": "3q-0"},
|
|
{"business_code": "3q-1", "label": "3q-1", **_CUSTOM_SLOT},
|
|
{"business_code": "3q-2", "label": "3q-2", **_CUSTOM_SLOT},
|
|
{"business_code": "3q-3", "label": "3q-3", **_CUSTOM_SLOT},
|
|
{"business_code": "3q-4", "label": "3q-4", **_CUSTOM_SLOT},
|
|
{"business_code": "3q-5", "label": "3q-5", **_CUSTOM_SLOT},
|
|
{"business_code": "3q-6", "label": "3q-6", **_CUSTOM_SLOT},
|
|
{"business_code": "6-1", "label": "6-1"},
|
|
{"business_code": "6-2", "label": "6-2"},
|
|
{"business_code": "6-3", "label": "6-3"},
|
|
{"business_code": "6-4", "label": "6-4"},
|
|
{"business_code": "7-1", "label": "7-1"},
|
|
{"business_code": "7-2", "label": "7-2"},
|
|
{"business_code": "7-3", "label": "7-3"},
|
|
{"business_code": "7-4", "label": "7-4"},
|
|
]
|
|
|
|
|
|
def _seed_type(seed: dict[str, Any]) -> tuple[bool, str]:
|
|
is_custom = bool(seed.get("is_custom", False))
|
|
mapping_type = str(seed.get("mapping_type") or "").strip()
|
|
if not mapping_type:
|
|
mapping_type = "自定义类型" if is_custom else "内置业务"
|
|
return is_custom, mapping_type
|
|
|
|
|
|
class BusinessMappingRepository:
|
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
|
self._sf = session_factory
|
|
|
|
@staticmethod
|
|
def _to_dict(row: BusinessMappingRow) -> dict[str, Any]:
|
|
return {
|
|
"business_code": row.business_code,
|
|
"label": row.label,
|
|
"mapping_type": row.mapping_type or "内置业务",
|
|
"is_custom": bool(row.is_custom),
|
|
"agent_id": row.agent_id,
|
|
"agent_name": row.agent_name,
|
|
"chain_id": row.chain_id,
|
|
"chain_title": row.chain_title,
|
|
"sort_order": row.sort_order,
|
|
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
|
|
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
|
}
|
|
|
|
async def list_mappings(self) -> list[dict[str, Any]]:
|
|
stmt = select(BusinessMappingRow).order_by(
|
|
BusinessMappingRow.sort_order.asc(), BusinessMappingRow.business_code.asc()
|
|
)
|
|
async with self._sf() as session:
|
|
result = await session.execute(stmt)
|
|
return [self._to_dict(row) for row in result.scalars()]
|
|
|
|
async def get_mapping(self, business_code: str) -> dict[str, Any] | None:
|
|
async with self._sf() as session:
|
|
row = await session.get(BusinessMappingRow, business_code)
|
|
return self._to_dict(row) if row is not None else None
|
|
|
|
async def update_mapping(
|
|
self,
|
|
business_code: str,
|
|
*,
|
|
agent_id=_UNSET,
|
|
agent_name=_UNSET,
|
|
chain_id=_UNSET,
|
|
chain_title=_UNSET,
|
|
) -> dict[str, Any] | None:
|
|
"""Update an existing (seeded) business row. Returns None if the code is unknown.
|
|
|
|
Only forwarded fields are touched; explicit ``None`` clears that field.
|
|
"""
|
|
async with self._sf() as session:
|
|
row = await session.get(BusinessMappingRow, business_code)
|
|
if row is None:
|
|
return None
|
|
if agent_id is not _UNSET:
|
|
row.agent_id = agent_id or None
|
|
if agent_name is not _UNSET:
|
|
row.agent_name = agent_name or None
|
|
if chain_id is not _UNSET:
|
|
row.chain_id = chain_id or None
|
|
if chain_title is not _UNSET:
|
|
row.chain_title = chain_title or None
|
|
row.updated_at = datetime.now(UTC)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
return self._to_dict(row)
|
|
|
|
async def create_custom_mapping(
|
|
self,
|
|
*,
|
|
business_code: str,
|
|
mapping_type: str,
|
|
) -> dict[str, Any] | None:
|
|
"""Create one administrator-defined mapping.
|
|
|
|
``business_code`` is deliberately the externally supplied goPath. The
|
|
case-insensitive uniqueness check mirrors frontend deep-link matching,
|
|
preventing two rows that could resolve from the same login URL.
|
|
"""
|
|
normalized_code = business_code.strip()
|
|
normalized_type = mapping_type.strip()
|
|
async with self._sf() as session:
|
|
existing = await session.scalar(
|
|
select(BusinessMappingRow).where(
|
|
func.lower(BusinessMappingRow.business_code) == normalized_code.lower()
|
|
)
|
|
)
|
|
if existing is not None:
|
|
return None
|
|
max_sort_order = await session.scalar(select(func.max(BusinessMappingRow.sort_order)))
|
|
now = datetime.now(UTC)
|
|
row = BusinessMappingRow(
|
|
business_code=normalized_code,
|
|
label=normalized_code,
|
|
mapping_type=normalized_type,
|
|
is_custom=True,
|
|
agent_id=None,
|
|
agent_name=None,
|
|
chain_id=None,
|
|
chain_title=None,
|
|
sort_order=(int(max_sort_order) if max_sort_order is not None else -1) + 1,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
session.add(row)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
return self._to_dict(row)
|
|
|
|
async def ensure_seed_defaults(self) -> int:
|
|
"""Idempotently insert the fixed business rows if missing. Returns inserted count.
|
|
|
|
Never overwrites an existing row's agent/chain (respects user config).
|
|
Re-syncs ``sort_order``, empty ``label``, and seed ``is_custom`` /
|
|
``mapping_type`` so newly inserted codes keep the intended table order
|
|
and 3q-1…3q-6 stay classified as custom type.
|
|
"""
|
|
inserted = 0
|
|
dirty = False
|
|
now = datetime.now(UTC)
|
|
async with self._sf() as session:
|
|
for idx, seed in enumerate(DEFAULT_BUSINESS_ROWS):
|
|
is_custom, mapping_type = _seed_type(seed)
|
|
existing = await session.get(BusinessMappingRow, seed["business_code"])
|
|
if existing is not None:
|
|
if existing.sort_order != idx:
|
|
existing.sort_order = idx
|
|
dirty = True
|
|
seed_label = str(seed.get("label") or "")
|
|
if seed_label and not (existing.label or "").strip():
|
|
existing.label = seed_label
|
|
dirty = True
|
|
if bool(existing.is_custom) != is_custom:
|
|
existing.is_custom = is_custom
|
|
dirty = True
|
|
if (existing.mapping_type or "") != mapping_type:
|
|
existing.mapping_type = mapping_type
|
|
dirty = True
|
|
continue
|
|
session.add(
|
|
BusinessMappingRow(
|
|
business_code=seed["business_code"],
|
|
label=seed.get("label", ""),
|
|
mapping_type=mapping_type,
|
|
is_custom=is_custom,
|
|
agent_id=None,
|
|
agent_name=None,
|
|
chain_id=None,
|
|
chain_title=None,
|
|
sort_order=idx,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
)
|
|
inserted += 1
|
|
dirty = True
|
|
if dirty:
|
|
await session.commit()
|
|
return inserted
|