1. 为渠道账户ID查询添加最小长度校验,统一分析模块常量引用 2. 新增扫码登录向导端点,完善文档说明 3. 优化配对统计接口,移除无效参数 4. 为出站箱接口添加批量上限与202状态码 5. 新增测试用例、访问规则、配额等模块的查询与校验参数 6. 新增适配器健康批量查询、健康检查触发接口 7. 统一告警、审计日志的错误处理方式 8. 新增插件配置账户ID支持,优化批量操作响应 9. 新增环境健康批量查询、Webhook限流与参数校验 10. 完善会话管理、审计日志的参数与文档说明 11. 修复导入模块的校验错误处理逻辑
467 lines
18 KiB
Python
467 lines
18 KiB
Python
"""目录查询域 Router(FR-14,DIR-QUERY-01~04 + DIR-01 + DIR-CACHE-CLEAR + Task 5)。
|
||
|
||
本 router 实现渠道通讯录目录查询的全部 HTTP 端点,覆盖用户搜索 / 群组搜索 /
|
||
用户资料 / 群组详情 / 群组成员 / 缓存清理 / 目录导出 / 搜索建议 / 批量资料
|
||
共 9 个操作。除导出端点返回 ``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`` / ``/suggestions`` /
|
||
``/batch_profiles``)先于动态单段路径(``/{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/suggestions
|
||
DIR-SUGGEST search_directory_suggestions
|
||
- 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
|
||
- POST /{channel_type}/{account_id}/directory/batch_profiles
|
||
DIR-BATCH batch_directory_profiles
|
||
- 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 urllib.parse import quote
|
||
|
||
from fastapi import APIRouter, Body, Depends, Path, 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 (
|
||
CURSOR,
|
||
DEFAULT_LIMIT,
|
||
SUGGESTION_LIMIT,
|
||
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"])
|
||
|
||
# 目录导出单次上限(handler 层与 router 层共享同一常量,单一事实源)
|
||
_DIRECTORY_EXPORT_MAX_LIMIT: int = 1000
|
||
|
||
# 排序字段白名单(各渠道适配器按需支持,未支持时在适配器内忽略)
|
||
_SORT_BY_QUERY: str | None = Query(
|
||
default=None,
|
||
description="排序字段(如 name),渠道支持情况不同,未支持时忽略",
|
||
)
|
||
_SORT_ORDER_QUERY: Literal["asc", "desc"] | None = Query(
|
||
default=None,
|
||
description="排序方向(asc 或 desc)",
|
||
)
|
||
|
||
# 账户/对端/群组路径参数最小长度校验(避免空串穿透到业务层)
|
||
_ACCOUNT_ID_PATH: str = Path(..., min_length=1, description="渠道账户 ID")
|
||
_PEER_ID_PATH: str = Path(..., min_length=1, description="对端用户 ID")
|
||
_GROUP_ID_PATH: str = Path(..., min_length=1, description="群组 ID")
|
||
|
||
|
||
# ---------------- 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 = _ACCOUNT_ID_PATH,
|
||
request: Request = None,
|
||
keyword: str | None = Query(default=None, description="搜索关键词"),
|
||
cursor: str | None = CURSOR,
|
||
limit: int = DEFAULT_LIMIT,
|
||
sort_by: str | None = _SORT_BY_QUERY,
|
||
sort_order: Literal["asc", "desc"] | None = _SORT_ORDER_QUERY,
|
||
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,
|
||
sort_by=sort_by,
|
||
sort_order=sort_order,
|
||
)
|
||
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 = _ACCOUNT_ID_PATH,
|
||
request: Request = None,
|
||
keyword: str = Query(..., min_length=1, description="搜索关键词"),
|
||
limit: int = SUGGESTION_LIMIT,
|
||
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 = _ACCOUNT_ID_PATH,
|
||
request: Request = None,
|
||
keyword: str | None = Query(default=None, description="搜索关键词"),
|
||
cursor: str | None = CURSOR,
|
||
limit: int = DEFAULT_LIMIT,
|
||
sort_by: str | None = _SORT_BY_QUERY,
|
||
sort_order: Literal["asc", "desc"] | None = _SORT_ORDER_QUERY,
|
||
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,
|
||
sort_by=sort_by,
|
||
sort_order=sort_order,
|
||
)
|
||
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 = _ACCOUNT_ID_PATH,
|
||
peer_id: str = _PEER_ID_PATH,
|
||
request: Request = None,
|
||
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=_DIRECTORY_EXPORT_MAX_LIMIT,
|
||
ge=1,
|
||
le=_DIRECTORY_EXPORT_MAX_LIMIT,
|
||
description=f"导出数量限制(1~{_DIRECTORY_EXPORT_MAX_LIMIT})",
|
||
)
|
||
|
||
|
||
@directory_router.post(
|
||
"/{channel_type}/{account_id}/directory/batch_profiles",
|
||
response_model=dict,
|
||
)
|
||
async def batch_directory_profiles(
|
||
channel_type: ChannelType,
|
||
account_id: str = _ACCOUNT_ID_PATH,
|
||
request: Request = None,
|
||
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 = _ACCOUNT_ID_PATH,
|
||
request: Request = None,
|
||
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*=UTF-8''{quote(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 = _ACCOUNT_ID_PATH,
|
||
group_id: str = _GROUP_ID_PATH,
|
||
request: Request = None,
|
||
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 = _ACCOUNT_ID_PATH,
|
||
group_id: str = _GROUP_ID_PATH,
|
||
request: Request = None,
|
||
cursor: str | None = CURSOR,
|
||
limit: int = DEFAULT_LIMIT,
|
||
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 = _ACCOUNT_ID_PATH,
|
||
request: Request = None,
|
||
scope: Literal["users", "groups", "all"] = 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。
|
||
"""
|
||
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)}
|