ForcePilot/backend/server/routers/channels/directory_router.py

429 lines
16 KiB
Python
Raw Normal View History

"""目录查询域 RouterFR-14DIR-QUERY-01~04 + DIR-01 + DIR-CACHE-CLEAR + Task 5
router 实现渠道通讯录目录查询的全部 HTTP 端点覆盖用户搜索 / 群组搜索 /
用户资料 / 群组详情 / 群组成员 / 缓存清理 / 目录导出共 7 个操作除导出端点
返回 ``StreamingResponse`` 文件流外其余端点统一采用模板 A控制面端口路由
通过 ``get_channel_use_cases`` 装配 ``ChannelUseCases`` ``directory`` 端口
调用类型化方法 ``ChannelControlService._executeControl`` 内部走控制面管道
auth permission rate_limit dispatch audit导出端点同样经控制面
管道执行handler 返回内容字符串router 包装为文件流以保持审计链路
鉴权策略全部端点使用 ``get_admin_user`` 依赖要求管理员或超级管理员角色
角色校验由依赖函数完成router 内不做角色判断规范 §4
路径设计静态后缀路径``/search`` / ``/export``先于动态单段路径
``/{peer_id}`` / ``/{group_id}``声明动态单段先于多段子资源路径
``/{group_id}/members``声明规范 §6.5避免静态路径被动态参数捕获
router 不自行设置 prefix根前缀 ``/channels`` ``channels_router`` 聚合
router 统一追加
端点清单对应05-目录查询域设计方案 v1.1§2.3 + Task 5
- GET /{channel_type}/{account_id}/directory/users/search
DIR-QUERY-01 search_directory_users
- GET /{channel_type}/{account_id}/directory/groups/search
DIR-QUERY-02 search_directory_groups
- GET /{channel_type}/{account_id}/directory/users/{peer_id}
DIR-QUERY-03 get_directory_user_profile
- GET /{channel_type}/{account_id}/directory/groups/{group_id}
DIR-01 get_directory_group_detail
- GET /{channel_type}/{account_id}/directory/groups/{group_id}/members
DIR-QUERY-04 get_directory_group_members
- DELETE /{channel_type}/{account_id}/directory/cache
DIR-CACHE-CLEAR clear_directory_cache
- POST /{channel_type}/{account_id}/directory/export
TASK-5 export_directory
"""
from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, Body, Depends, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from yuxi.channels.contract.dtos.channel import ChannelType
from yuxi.channels.contract.dtos.directory import (
BatchDirectoryProfilesQuery,
ClearDirectoryCacheCmd,
DirectoryEntryType,
DirectoryQuery,
)
from yuxi.storage.postgres.models_business import User
from server.routers.channels import (
build_operator,
get_channel_use_cases,
raiseOnControlFailure,
serialize_control_data,
)
from server.utils.auth_middleware import get_admin_user
directory_router = APIRouter(tags=["channels-directory"])
# ---------------- Endpoints ----------------
@directory_router.get(
"/{channel_type}/{account_id}/directory/users/search",
response_model=dict,
)
async def search_directory_users(
channel_type: ChannelType,
account_id: str,
request: Request,
keyword: str | None = Query(default=None, description="搜索关键词"),
cursor: str | None = Query(default=None, description="游标分页标记"),
limit: int = Query(default=20, ge=1, le=100, description="分页大小"),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""搜索渠道用户目录FR-14DIR-QUERY-01
5min TTL 缓存渠道未注册目录适配器或目录开关关闭返回 501
对应控制面操作 ``directory/users`` ``ChannelControlService.searchDirectoryUsers``
内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
"""
operator = build_operator(current_user, request)
query = DirectoryQuery(keyword=keyword, cursor=cursor, limit=limit)
result = await use_cases.directory.searchDirectoryUsers(
channel_type=channel_type,
account_id=account_id,
query=query,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@directory_router.get(
"/{channel_type}/{account_id}/directory/suggestions",
response_model=dict,
)
async def search_directory_suggestions(
channel_type: ChannelType,
account_id: str,
request: Request,
keyword: str = Query(..., min_length=1, description="搜索关键词"),
limit: int = Query(default=10, ge=1, le=20, description="建议数量"),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""搜索渠道目录建议FR-14
根据关键词搜索渠道侧目录建议用于输入联想5min TTL 缓存对应控制
面操作 ``directory/search_suggestions``
``ChannelControlService.searchDirectorySuggestions`` 内部构造
``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
"""
operator = build_operator(current_user, request)
query = DirectoryQuery(keyword=keyword, limit=limit)
result = await use_cases.directory.searchDirectorySuggestions(
channel_type=channel_type,
account_id=account_id,
query=query,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@directory_router.get(
"/{channel_type}/{account_id}/directory/groups/search",
response_model=dict,
)
async def search_directory_groups(
channel_type: ChannelType,
account_id: str,
request: Request,
keyword: str | None = Query(default=None, description="搜索关键词"),
cursor: str | None = Query(default=None, description="游标分页标记"),
limit: int = Query(default=20, ge=1, le=100, description="分页大小"),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""搜索渠道群组目录FR-14DIR-QUERY-02
5min TTL 缓存对应控制面操作 ``directory/groups``
``ChannelControlService.searchDirectoryGroups`` 内部构造 ``ControlCmd``
并委托 ``_executeControl`` 执行控制面管道
"""
operator = build_operator(current_user, request)
query = DirectoryQuery(keyword=keyword, cursor=cursor, limit=limit)
result = await use_cases.directory.searchDirectoryGroups(
channel_type=channel_type,
account_id=account_id,
query=query,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@directory_router.get(
"/{channel_type}/{account_id}/directory/users/{peer_id}",
response_model=dict,
)
async def get_directory_user_profile(
channel_type: ChannelType,
account_id: str,
peer_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询渠道用户资料FR-14DIR-QUERY-03
按对端 ID 查询5min TTL 缓存对应控制面操作 ``directory/user_profile``
``ChannelControlService.getDirectoryUserProfile`` 内部构造
``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
协议翻译HTTP 路径参数 ``peer_id`` 映射为用例方法 ``user_id`` 入参
以对齐 ``DirectoryPort.getDirectoryUserProfile`` 的契约命名
"""
operator = build_operator(current_user, request)
result = await use_cases.directory.getDirectoryUserProfile(
channel_type=channel_type,
account_id=account_id,
user_id=peer_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
class BatchProfilesRequest(BaseModel):
"""批量目录资料查询请求。"""
model_config = ConfigDict(frozen=True)
peer_ids: list[str] = Field(
...,
min_length=1,
description="待查询的对端 ID 列表(可能是用户 ID 或群组 ID",
)
types: list[Literal["user", "group"]] | None = Field(
default=None,
description="按条目类型过滤user / group不传入则查询全部类型",
)
class ExportDirectoryRequest(BaseModel):
"""目录导出请求Task 5"""
model_config = ConfigDict(frozen=True)
scope: Literal["users", "groups"] = Field(
default="users",
description="导出范围users 或 groups",
)
keyword: str | None = Field(
default=None,
description="搜索关键词",
)
format: Literal["csv", "json"] = Field(
default="csv",
description="导出格式csv 或 json",
)
limit: int = Field(
default=1000,
ge=1,
le=1000,
description="导出数量限制1~1000",
)
@directory_router.post(
"/{channel_type}/{account_id}/directory/batch_profiles",
response_model=dict,
)
async def batch_directory_profiles(
channel_type: ChannelType,
account_id: str,
request: Request,
payload: BatchProfilesRequest = Body(...),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""批量查询对端资料(用户或群组)。
给定一组 peer_id逐个尝试查询用户资料若用户不存在则尝试查询
群组详情结果统一返回为目录条目type 区分 user/group用于路由
绑定列表等需要把抽象 ID 翻译为可读名称的场景5min TTL 缓存
对应控制面操作 ``directory/batch_profiles``
``ChannelControlService.batchGetDirectoryProfiles`` 内部构造
``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
"""
operator = build_operator(current_user, request)
types = tuple(DirectoryEntryType(t) for t in payload.types) if payload.types is not None else None
query = BatchDirectoryProfilesQuery(
peer_ids=tuple(payload.peer_ids),
types=types,
)
result = await use_cases.directory.batchGetDirectoryProfiles(
channel_type=channel_type,
account_id=account_id,
query=query,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@directory_router.post(
"/{channel_type}/{account_id}/directory/export",
)
async def export_directory(
channel_type: ChannelType,
account_id: str,
request: Request,
payload: ExportDirectoryRequest = Body(...),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> StreamingResponse:
"""导出目录数据FR-14Task 5
搜索渠道侧用户或群组目录将结果格式化为 CSV JSON 文件流返回
经控制面管道执行 ``directory/export`` 操作保持审计链路handler 返回
``{"content": str, "format": str, "filename": str}``router 包装为
``StreamingResponse`` 触发浏览器下载
"""
operator = build_operator(current_user, request)
result = await use_cases.directory.exportDirectory(
channel_type=channel_type,
account_id=account_id,
scope=payload.scope,
keyword=payload.keyword,
format=payload.format,
limit=payload.limit,
operator=operator,
)
raiseOnControlFailure(result)
data = serialize_control_data(result.data)
content: str = data["content"]
fmt: str = data["format"]
filename: str = data["filename"]
media_type = "text/csv; charset=utf-8" if fmt == "csv" else "application/json; charset=utf-8"
async def _stream():
yield content.encode("utf-8")
return StreamingResponse(
_stream(),
media_type=media_type,
headers={
"Content-Disposition": f"attachment; filename={filename}",
},
)
@directory_router.get(
"/{channel_type}/{account_id}/directory/groups/{group_id}",
response_model=dict,
)
async def get_directory_group_detail(
channel_type: ChannelType,
account_id: str,
group_id: str,
request: Request,
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询渠道群组详情FR-14DIR-01
group_id 查询5min TTL 缓存对应控制面操作 ``directory/group_detail``
``ChannelControlService.getDirectoryGroupDetail`` 内部构造
``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道
"""
operator = build_operator(current_user, request)
result = await use_cases.directory.getDirectoryGroupDetail(
channel_type=channel_type,
account_id=account_id,
group_id=group_id,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@directory_router.get(
"/{channel_type}/{account_id}/directory/groups/{group_id}/members",
response_model=dict,
)
async def get_directory_group_members(
channel_type: ChannelType,
account_id: str,
group_id: str,
request: Request,
cursor: str | None = Query(default=None, description="游标分页标记"),
limit: int = Query(default=20, ge=1, le=100, description="分页大小"),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""查询渠道群组成员FR-14DIR-QUERY-04
5min TTL 缓存对应控制面操作 ``directory/group_members``
``ChannelControlService.getDirectoryGroupMembers`` 内部构造 ``ControlCmd``
并委托 ``_executeControl`` 执行控制面管道
分页参数仅含 ``cursor`` ``limit``构造 ``DirectoryQuery`` 时不传
``keyword``群组成员查询无关键词语义
"""
operator = build_operator(current_user, request)
query = DirectoryQuery(cursor=cursor, limit=limit)
result = await use_cases.directory.getDirectoryGroupMembers(
channel_type=channel_type,
account_id=account_id,
group_id=group_id,
query=query,
operator=operator,
)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}
@directory_router.delete(
"/{channel_type}/{account_id}/directory/cache",
response_model=dict,
)
async def clear_directory_cache(
channel_type: ChannelType,
account_id: str,
request: Request,
scope: str = Query(
default="all",
description="清理范围users / groups / all",
),
use_cases=Depends(get_channel_use_cases),
current_user: User = Depends(get_admin_user),
) -> dict[str, Any]:
"""清理目录缓存DIR-CACHE-CLEAR
对应控制面操作 ``directory/cache_clear``
``ChannelControlService.clearDirectoryCache`` 内部构造 ``ControlCmd``
并委托 ``_executeControl`` 执行控制面管道清理指定渠道账户的目录
查询缓存用户 / 群组 / 全部供管理员在渠道侧数据变更后手动刷新
缓存编排链路HTTP 入参 ``build_operator`` 构造操作人 构造
``ClearDirectoryCacheCmd`` ``use_cases.directory.clearDirectoryCache``
调用端口方法 控制面管道检查适配器是否实现 ``clearCache`` 调用
适配器清理 + ``CachePort`` 失效本地缓存 ``ControlResult``
``raiseOnControlFailure`` 转译失败 ``serialize_control_data`` 序列化
清理结果返回适配器未实现 ``clearCache`` 时返回 501
scope 枚举校验在 ``ClearDirectoryCacheCmd.__post_init__`` 构造时即执行
非法取值直接抛 400 ``ValidationError``router 路径未进入控制面管道
handler ``_directoryCacheClear`` 同步校验作为非 router 调用方的
防御兜底 ``user_id`` / ``group_id`` 等业务参数的分层校验策略一致
"""
operator = build_operator(current_user, request)
cmd = ClearDirectoryCacheCmd(
channel_type=channel_type,
account_id=account_id,
operator=operator,
scope=scope,
)
result = await use_cases.directory.clearDirectoryCache(cmd)
raiseOnControlFailure(result)
return {"success": True, "data": serialize_control_data(result.data)}