1. 新增TraceId中间件实现全链路请求追踪 2. 新增在途请求计数器与中间件支持优雅关停 3. 重构全局异常处理器,统一三模块异常响应格式 4. 优化渠道查询接口,新增多维度过滤与模糊匹配 5. 重构channels模块数据库会话管理,避免事务冲突 6. 新增目录搜索建议与目录导出功能
429 lines
16 KiB
Python
429 lines
16 KiB
Python
"""目录查询域 Router(FR-14,DIR-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-14,DIR-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-14,DIR-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-14,DIR-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-14,Task 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-14,DIR-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-14,DIR-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)}
|