feat:对接中心平台,完善接口鉴权

This commit is contained in:
2026-10-01 04:01:44 -07:00
parent 8e37d30ef2
commit 618290d852
20 changed files with 391 additions and 70 deletions
+14
View File
@@ -3,6 +3,7 @@
from copy import deepcopy from copy import deepcopy
from app.actions.base import BaseActionHandler from app.actions.base import BaseActionHandler
from app.resources.material_fields import editable_fields, known_prop_keys
from app.schemas.actions import UpdateAction from app.schemas.actions import UpdateAction
from app.schemas.screen import Screen from app.schemas.screen import Screen
@@ -16,6 +17,19 @@ class UpdateHandler(BaseActionHandler[UpdateAction]):
if action.section == "style" and target.style is None: if action.section == "style" and target.style is None:
target.style = {} target.style = {}
# props 第一层不能新造字段:渲染器不会读取约定外的字段,新造只会“成功”却没有效果
if action.section == "props":
known = known_prop_keys(target)
if known is not None and action.path[0] not in known:
labels = "、".join(
f"{field['label']}={field['section']}.{field['path']}"
for field in editable_fields(target.type)
)
raise ValueError(
f"组件 {target.type} 的 props 没有字段 {action.path[0]!r},"
f"不能新造组件级属性;可编辑字段:{labels or '无'}"
)
# 获取动作指定的属性区域,作为路径遍历的起点 # 获取动作指定的属性区域,作为路径遍历的起点
current = getattr(target, action.section) current = getattr(target, action.section)
# 逐段访问路径,只有到达末级时才写入新值 # 逐段访问路径,只有到达末级时才写入新值
+15 -5
View File
@@ -1,24 +1,34 @@
"""大屏 Agent 启动与澄清恢复接口""" """大屏 Agent 启动与澄清恢复接口"""
from fastapi import APIRouter, Request from fastapi import APIRouter, Depends, Request
from app.common.sse import AgentEventResponse from app.common.sse import AgentEventResponse
from app.core.auth import get_current_user
from app.schemas.agent import ResumeRequest from app.schemas.agent import ResumeRequest
from app.schemas.request import DesignRequest from app.schemas.request import DesignRequest
from app.schemas.user import CurrentUser
router = APIRouter(prefix="/agent", tags=["Agent"]) router = APIRouter(prefix="/agent", tags=["Agent"])
@router.post("/run", response_class=AgentEventResponse, status_code=200) @router.post("/run", response_class=AgentEventResponse, status_code=200)
async def stream_agent(body: DesignRequest, request: Request): async def stream_agent(
body: DesignRequest,
request: Request,
user: CurrentUser = Depends(get_current_user),
):
service = request.app.state.agent_service service = request.app.state.agent_service
# 在发送流响应头之前完成会话校验并占用任务 # 在发送流响应头之前完成会话校验并占用任务
execution = await service.prepare_run(body) execution = await service.prepare_run(body, user.id)
return AgentEventResponse(service, execution, request) return AgentEventResponse(service, execution, request)
@router.post("/resume", response_class=AgentEventResponse, status_code=200) @router.post("/resume", response_class=AgentEventResponse, status_code=200)
async def stream_resume_agent(body: ResumeRequest, request: Request): async def stream_resume_agent(
body: ResumeRequest,
request: Request,
user: CurrentUser = Depends(get_current_user),
):
service = request.app.state.agent_service service = request.app.state.agent_service
execution = await service.prepare_resume(body) execution = await service.prepare_resume(body, user.id)
return AgentEventResponse(service, execution, request) return AgentEventResponse(service, execution, request)
+12 -5
View File
@@ -1,28 +1,35 @@
"""会话创建与历史查询接口""" """会话创建与历史查询接口"""
from fastapi import APIRouter, Request from fastapi import APIRouter, Depends, Request
from app.common.response import ApiResponse, success_response from app.common.response import ApiResponse, success_response
from app.core.auth import get_current_user
from app.core.exceptions import BizException from app.core.exceptions import BizException
from app.schemas.conversation import ConversationCreated, ConversationDetail from app.schemas.conversation import ConversationCreated, ConversationDetail
from app.schemas.user import CurrentUser
router = APIRouter(prefix="/conversations", tags=["Conversation"]) router = APIRouter(prefix="/conversations", tags=["Conversation"])
@router.post("") @router.post("")
async def create_conversation( async def create_conversation(
request: Request, request: Request, user: CurrentUser = Depends(get_current_user)
) -> ApiResponse[ConversationCreated]: ) -> ApiResponse[ConversationCreated]:
store = request.app.state.conversation_store store = request.app.state.conversation_store
return success_response(ConversationCreated(conversation_id=await store.create())) return success_response(
ConversationCreated(conversation_id=await store.create(user.id))
)
@router.get("/{conversation_id}") @router.get("/{conversation_id}")
async def get_conversation( async def get_conversation(
conversation_id: str, request: Request conversation_id: str,
request: Request,
user: CurrentUser = Depends(get_current_user),
) -> ApiResponse[ConversationDetail]: ) -> ApiResponse[ConversationDetail]:
store = request.app.state.conversation_store store = request.app.state.conversation_store
if not await store.exists(conversation_id): # 他人的会话与不存在同样返回 404
if not await store.exists(conversation_id, user.id):
raise BizException(404, "会话不存在或已过期") raise BizException(404, "会话不存在或已过期")
turns = await store.load_turns(conversation_id, None) turns = await store.load_turns(conversation_id, None)
return success_response( return success_response(
+117
View File
@@ -0,0 +1,117 @@
"""用户中心客户端:用请求携带的 token 换取当前用户,结果短时缓存"""
import hashlib
import time
from functools import cache
import httpx
from loguru import logger
from app.config import Settings, settings
from app.core.exceptions import BizException
from app.schemas.user import CurrentUser
# 用户中心 401 时提示调用方用 Bearer token 重新认证
BEARER_CHALLENGE = {"WWW-Authenticate": "Bearer"}
class UserCenterClient:
def __init__(
self,
config: Settings | None = None,
transport: httpx.AsyncBaseTransport | None = None,
):
self._config = config if config is not None else settings
self._transport = transport
self._client: httpx.AsyncClient | None = None
# token 摘要 -> (过期时刻, 用户);不在内存中保存明文 token
self._cache: dict[str, tuple[float, CurrentUser]] = {}
def _get_client(self) -> httpx.AsyncClient:
if self._client is None:
self._client = httpx.AsyncClient(
base_url=self._config.user_center_url,
timeout=self._config.user_center_timeout_seconds,
transport=self._transport,
)
return self._client
def _cached(self, key: str) -> CurrentUser | None:
entry = self._cache.get(key)
if entry is None:
return None
if entry[0] <= time.monotonic():
del self._cache[key]
return None
return entry[1]
def _remember(self, key: str, user: CurrentUser) -> None:
ttl = self._config.auth_cache_ttl_seconds
if not ttl:
return
now = time.monotonic()
# 写入时顺带清掉过期项,缓存规模不超过 TTL 内的活跃 token 数
for stale in [k for k, (expires, _) in self._cache.items() if expires <= now]:
del self._cache[stale]
self._cache[key] = (now + ttl, user)
async def fetch_current_user(self, token: str) -> CurrentUser:
key = hashlib.sha256(token.encode()).hexdigest()
user = self._cached(key)
if user is not None:
return user
try:
response = await self._get_client().get(
"/api/users/me", headers={"Authorization": f"Bearer {token}"}
)
except httpx.HTTPError as error:
logger.opt(exception=error).warning("用户中心请求失败")
raise BizException(503, "用户中心不可用") from error
if response.status_code >= 500:
logger.warning("用户中心返回 HTTP {}", response.status_code)
raise BizException(503, "用户中心不可用")
try:
body = response.json()
except ValueError:
body = None
if not isinstance(body, dict):
body = {}
if response.status_code >= 400:
# 用户中心与本服务的错误响应同构,直接透传状态码和提示
raise BizException(
response.status_code,
body.get("msg") or "认证失败",
BEARER_CHALLENGE if response.status_code == 401 else None,
)
try:
user = CurrentUser.model_validate(body["data"])
except (KeyError, TypeError, ValueError) as error:
logger.warning("用户中心返回的数据格式无效")
raise BizException(503, "用户中心不可用") from error
self._remember(key, user)
return user
async def aclose(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
@cache
def _cached_client() -> UserCenterClient:
return UserCenterClient()
def get_user_center() -> UserCenterClient:
"""共享入口;适用于单个服务事件循环"""
return _cached_client()
async def close_user_center() -> None:
"""在应用关闭阶段调用"""
if _cached_client.cache_info().currsize:
await _cached_client().aclose()
_cached_client.cache_clear()
+25
View File
@@ -79,7 +79,32 @@ class Settings:
default_factory=lambda: int(os.getenv("MONGODB_CHECKPOINT_TTL", "604800")) default_factory=lambda: int(os.getenv("MONGODB_CHECKPOINT_TTL", "604800"))
) )
# 用户中心:请求携带的 token 交给它校验,换回 user_id
user_center_url: str = field(
default_factory=lambda: (
os.getenv("USER_CENTER_URL") or "http://127.0.0.1:8100"
).rstrip("/")
)
user_center_timeout_seconds: float = field(
default_factory=lambda: float(os.getenv("USER_CENTER_TIMEOUT_SECONDS", "5"))
)
# 关闭后不校验 token,所有请求视为同一个本地调试用户,仅用于开发
auth_enabled: bool = field(
default_factory=lambda: (
os.getenv("AUTH_ENABLED", "true").strip().lower()
not in ("false", "0", "no", "off")
)
)
# token 校验结果缓存时长(秒),登出或禁用后最多延迟这么久失效,0 表示不缓存
auth_cache_ttl_seconds: int = field(
default_factory=lambda: int(os.getenv("AUTH_CACHE_TTL_SECONDS", "30"))
)
def __post_init__(self): def __post_init__(self):
if self.user_center_timeout_seconds <= 0:
raise ValueError("USER_CENTER_TIMEOUT_SECONDS 必须是正数")
if self.auth_cache_ttl_seconds < 0:
raise ValueError("AUTH_CACHE_TTL_SECONDS 必须是非负整数")
if self.mongodb_checkpoint_ttl < 0: if self.mongodb_checkpoint_ttl < 0:
raise ValueError("MONGODB_CHECKPOINT_TTL 必须是非负整数") raise ValueError("MONGODB_CHECKPOINT_TTL 必须是非负整数")
if self.conversation_empty_ttl <= 0: if self.conversation_empty_ttl <= 0:
+28
View File
@@ -0,0 +1,28 @@
"""接口鉴权:从 Authorization 头取出用户中心 token,换取当前用户"""
from fastapi import Depends, Request
from app.clients.user_center import BEARER_CHALLENGE, UserCenterClient, get_user_center
from app.config import settings
from app.core.exceptions import BizException
from app.schemas.user import CurrentUser
# AUTH_ENABLED=false 时所有请求共用的本地调试用户
DEV_USER = CurrentUser(id=0, username="dev")
def _bearer_token(request: Request) -> str:
scheme, _, token = request.headers.get("Authorization", "").partition(" ")
token = token.strip()
if scheme.lower() != "bearer" or not token:
raise BizException(401, "请先登录", BEARER_CHALLENGE)
return token
async def get_current_user(
request: Request, client: UserCenterClient = Depends(get_user_center)
) -> CurrentUser:
"""同一请求内 FastAPI 会缓存结果,路由级和函数级重复声明只校验一次"""
if not settings.auth_enabled:
return DEV_USER
return await client.fetch_current_user(_bearer_token(request))
+13 -2
View File
@@ -16,14 +16,25 @@ from app.exceptions import AppError, DecisionError
class BizException(AppError): class BizException(AppError):
"""可安全返回给调用方的业务错误""" """可安全返回给调用方的业务错误"""
def __init__(self, code: int, msg: str = "请求失败") -> None: def __init__(
self,
code: int,
msg: str = "请求失败",
headers: dict[str, str] | None = None,
) -> None:
self.code = code self.code = code
self.msg = msg self.msg = msg
# 协议响应头,例如 401 时的 WWW-Authenticate
self.headers = headers
super().__init__(msg) super().__init__(msg)
async def handle_biz_exception(_request: Request, exc: BizException) -> JSONResponse: async def handle_biz_exception(_request: Request, exc: BizException) -> JSONResponse:
return JSONResponse(status_code=exc.code, content=error_content(exc.code, exc.msg)) return JSONResponse(
status_code=exc.code,
content=error_content(exc.code, exc.msg),
headers=exc.headers,
)
async def handle_validation_exception( async def handle_validation_exception(
+14 -5
View File
@@ -3,12 +3,13 @@
import asyncio import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import Depends, FastAPI
from loguru import logger from loguru import logger
from app.api.agent import router from app.api.agent import router
from app.api.conversations import router as conversation_router from app.api.conversations import router as conversation_router
from app.api.materials import router as material_router from app.api.materials import router as material_router
from app.core.auth import get_current_user
from app.core.exceptions import register_exception_handlers from app.core.exceptions import register_exception_handlers
from app.core.logging import setup_logging from app.core.logging import setup_logging
@@ -17,6 +18,7 @@ from app.core.logging import setup_logging
async def lifespan(application: FastAPI): async def lifespan(application: FastAPI):
setup_logging() setup_logging()
from app.clients.decision import close_decision_engine from app.clients.decision import close_decision_engine
from app.clients.user_center import close_user_center
from app.observability.tracing import flush_tracing from app.observability.tracing import flush_tracing
from app.processor.graph import build_graph from app.processor.graph import build_graph
from app.services.agent import AgentService from app.services.agent import AgentService
@@ -42,9 +44,12 @@ async def lifespan(application: FastAPI):
await close_decision_engine() await close_decision_engine()
finally: finally:
try: try:
await asyncio.to_thread(flush_tracing) await close_user_center()
finally: finally:
await logger.complete() try:
await asyncio.to_thread(flush_tracing)
finally:
await logger.complete()
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -52,8 +57,12 @@ def create_app() -> FastAPI:
# 统一处理请求校验、HTTP 错误和未捕获异常 # 统一处理请求校验、HTTP 错误和未捕获异常
register_exception_handlers(application) register_exception_handlers(application)
# 统一 /api 前缀,方便前端代理和反向代理只配置一条规则 # 统一 /api 前缀,方便前端代理和反向代理只配置一条规则
application.include_router(router, prefix="/api") # 会话和 agent 接口需登录,物料目录是全局公开数据
application.include_router(conversation_router, prefix="/api") authenticated = [Depends(get_current_user)]
application.include_router(router, prefix="/api", dependencies=authenticated)
application.include_router(
conversation_router, prefix="/api", dependencies=authenticated
)
application.include_router(material_router, prefix="/api") application.include_router(material_router, prefix="/api")
return application return application
+10 -4
View File
@@ -61,24 +61,28 @@ async def ainvoke_screen(
*, *,
thread_id: str, thread_id: str,
session_id: str | None = None, session_id: str | None = None,
user_id: int | None = None,
) -> Any: ) -> Any:
"""每轮调用记录一个根 span,返回、等待用户或异常时结束。 """每轮调用记录一个根 span,返回、等待用户或异常时结束。
session_id 由调用方在首次与恢复时一致传入(通常为 conversation_id)。 session_id 由调用方在首次与恢复时一致传入(通常为 conversation_id)。
未传时始终使用 thread_id,不从仅首次请求才有的 payload 推断。 未传时始终使用 thread_id,不从仅首次请求才有的 payload 推断。
user_id 为用户中心的用户 ID,接口调用时传入,本地脚本不传。
""" """
with _screen_trace(payload, thread_id, session_id) as (config, root): with _screen_trace(payload, thread_id, session_id, user_id) as (config, root):
result = await graph.ainvoke(payload, config=config) result = await graph.ainvoke(payload, config=config)
_finish_trace(root, result) _finish_trace(root, result)
return result return result
async def astream_screen(graph, payload, *, thread_id, session_id=None): async def astream_screen(
graph, payload, *, thread_id, session_id=None, user_id=None
):
"""同一轮流保持同一个追踪上下文,关闭流时结束追踪""" """同一轮流保持同一个追踪上下文,关闭流时结束追踪"""
from contextlib import aclosing from contextlib import aclosing
result = {} result = {}
with _screen_trace(payload, thread_id, session_id) as (config, root): with _screen_trace(payload, thread_id, session_id, user_id) as (config, root):
async with aclosing( async with aclosing(
graph.astream(payload, config=config, stream_mode=["custom", "values"]) graph.astream(payload, config=config, stream_mode=["custom", "values"])
) as events: ) as events:
@@ -98,7 +102,7 @@ def _finish_trace(root, result):
@contextmanager @contextmanager
def _screen_trace(payload, thread_id, session_id): def _screen_trace(payload, thread_id, session_id, user_id=None):
config = {"configurable": {"thread_id": thread_id}} config = {"configurable": {"thread_id": thread_id}}
client = _ensure_client() if tracing_enabled() else None client = _ensure_client() if tracing_enabled() else None
if client is None: if client is None:
@@ -128,6 +132,8 @@ def _screen_trace(payload, thread_id, session_id):
propagate_attributes( propagate_attributes(
trace_name=TRACE_NAME, trace_name=TRACE_NAME,
session_id=session_id or thread_id, session_id=session_id or thread_id,
# 用户中心的 user_id,面板 Users 页按用户聚合;脚本调用没有用户时不设
user_id=str(user_id) if user_id is not None else None,
tags=[FEATURE_TAG], tags=[FEATURE_TAG],
metadata={ metadata={
"thread_id": thread_id, "thread_id": thread_id,
+27
View File
@@ -11,6 +11,7 @@ from app.clients.llm import get_llm
from app.processor.base import BaseNode from app.processor.base import BaseNode
from app.processor.state import QueryState from app.processor.state import QueryState
from app.prompt.action import build_action_prompt from app.prompt.action import build_action_prompt
from app.resources.material_fields import editable_fields
from app.schemas.action_validation import ActionValidationError, schema_errors from app.schemas.action_validation import ActionValidationError, schema_errors
from app.schemas.actions import ActionBatch, AddAction from app.schemas.actions import ActionBatch, AddAction
from app.schemas.operations import is_direct_operation from app.schemas.operations import is_direct_operation
@@ -58,6 +59,28 @@ def action_block_reason(state: QueryState) -> str | None:
return None return None
def update_fields(state: QueryState) -> dict[str, list[dict]]:
"""按组件 ID 汇总可能被属性更新的已有组件的可编辑字段,没有字段的组件省略"""
plan = state.get("screen_plan")
if plan is None:
target_ids = [
target
for change in state["requirement"].changes
if change.target_scope == "nodes"
for target in change.target_ids
]
else:
target_ids = [
item.target_id for item in plan.components if item.disposition == "modify"
]
nodes = {node.id: node for node in state["input_context"].screen.nodes}
result = {}
for target in dict.fromkeys(target_ids):
if target in nodes and (fields := editable_fields(nodes[target].type)):
result[target] = fields
return result
class ActionNode(BaseNode): class ActionNode(BaseNode):
name = "action_node" name = "action_node"
@@ -92,6 +115,10 @@ class ActionNode(BaseNode):
payload["screen_plan"] = state["screen_plan"].model_dump(mode="json") payload["screen_plan"] = state["screen_plan"].model_dump(mode="json")
payload["component_materials"] = plan_materials(state) payload["component_materials"] = plan_materials(state)
# 提供待修改组件的属性面板字段,让模型把用户说的“标题”“颜色”等对应到真实路径
if fields := update_fields(state):
payload["editable_fields"] = fields
# 如果本轮是动作修正,提供修正建议、上一轮动作和校验错误供模型参考 # 如果本轮是动作修正,提供修正建议、上一轮动作和校验错误供模型参考
if state.get("repair_plan") is not None: if state.get("repair_plan") is not None:
payload.update( payload.update(
+2
View File
@@ -24,6 +24,8 @@ properties 只包含需要修改的画布顶层字段,宽高为最终绝对值
依据快照选择 props / style 下的字段路径,不猜测不存在的嵌套结构 依据快照选择 props / style 下的字段路径,不猜测不存在的嵌套结构
path 中字符串是对象键,整数是数组下标;中间路径必须存在,数组下标不能越界 path 中字符串是对象键,整数是数组下标;中间路径必须存在,数组下标不能越界
只替换指定字段,不做深度合并;null 表示赋值,不表示删除 只替换指定字段,不做深度合并;null 表示赋值,不表示删除
提供 editable_fields 时,它是该组件属性面板的字段:用户说的名称(如名字、标题、颜色)优先对应 label 相符的字段,使用其 section 和 path
props 第一层只能使用组件已有或物料默认配置中的字段,不新造组件级属性;更深层的配置可以按需新增
不通过修改属性改变组件类型(如把 series 的 type 改成其他图表类型),组件类型由节点 type 和物料决定 不通过修改属性改变组件类型(如把 series 的 type 改成其他图表类型),组件类型由节点 type 和物料决定
style 为 null 时可用单层路径添加属性,保留未要求修改的配置 style 为 null 时可用单层路径添加属性,保留未要求修改的配置
""", """,
+9 -29
View File
@@ -1,4 +1,4 @@
"""读取前端组件目录:启动时载入进程内存缓存,未载入时回退读取本地 JSON 文件""" """查询前端组件目录:目录只来自启动时从 MongoDB 载入的进程内存缓存,没有文件回退"""
from pathlib import Path from pathlib import Path
@@ -8,8 +8,6 @@ from app.schemas.component_resource import (
ComponentResult, ComponentResult,
) )
DEFAULT_CATALOG_PATH = Path(__file__).resolve().parent / "catalog" / "components.json"
# 进程内共享的组件目录,由启动流程载入;agent 与前端接口读取同一份 # 进程内共享的组件目录,由启动流程载入;agent 与前端接口读取同一份
_cached_catalog: ComponentCatalog | None = None _cached_catalog: ComponentCatalog | None = None
@@ -23,36 +21,24 @@ def get_catalog_cache() -> ComponentCatalog | None:
return _cached_catalog return _cached_catalog
def load_catalog_file(path: str | Path | None = None) -> ComponentCatalog | None: def load_catalog_file(path: str | Path) -> ComponentCatalog:
"""读取导出的目录文件,只供导入脚本使用,运行时不读取文件"""
# 如果传入的是相对路径,以项目根目录为基准,避免受启动时工作目录影响 # 如果传入的是相对路径,以项目根目录为基准,避免受启动时工作目录影响
target = Path(path) if path is not None else DEFAULT_CATALOG_PATH target = Path(path)
if not target.is_absolute(): if not target.is_absolute():
target = Path(__file__).resolve().parents[2] / target target = Path(__file__).resolve().parents[2] / target
try: # utf-8-sig 同时兼容带 BOM 和不带 BOM 的 UTF-8 文件;损坏或不合法直接报错
# utf-8-sig 同时兼容带 BOM 和不带 BOM 的 UTF-8 文件 return ComponentCatalog.model_validate_json(target.read_text(encoding="utf-8-sig"))
content = target.read_text(encoding="utf-8-sig")
except FileNotFoundError:
return None
# 文件损坏或定义不合法直接报错,不能伪装成查无结果
return ComponentCatalog.model_validate_json(content)
class ComponentProvider: class ComponentProvider:
def __init__(self, catalog_path: str | Path | None = None):
# 指定路径时始终读取该文件;未指定时优先使用启动缓存,缓存为空再读默认文件
self.catalog_path = catalog_path
def query(self, query: ComponentQuery) -> ComponentResult: def query(self, query: ComponentQuery) -> ComponentResult:
catalog = ( catalog = _cached_catalog
_cached_catalog
if self.catalog_path is None and _cached_catalog is not None
else load_catalog_file(self.catalog_path)
)
if catalog is None: if catalog is None:
# 如果目录文件不存在,返回未支持状态和原因,由调用方决定失败处理方式 # 启动时未载入目录,返回未支持状态和原因,由调用方决定失败处理方式
return ComponentResult( return ComponentResult(
status="unsupported", status="unsupported",
message="尚未配置前端组件目录,请先导出真实组件定义", message="物料目录未载入,请确认已配置 MongoDB 并导入物料",
) )
# 如果提供关键词,去除首尾空白并统一大小写,未提供时不启用关键词筛选 # 如果提供关键词,去除首尾空白并统一大小写,未提供时不启用关键词筛选
@@ -92,9 +78,3 @@ class ComponentProvider:
catalog_version=catalog.version, catalog_version=catalog.version,
message="已找到组件定义" if matches else "已查询组件目录,未找到匹配的组件", message="已找到组件定义" if matches else "已查询组件目录,未找到匹配的组件",
) )
if __name__ == "__main__":
provider = ComponentProvider()
result = provider.query(ComponentQuery())
print(result.model_dump_json(indent=2))
+50
View File
@@ -0,0 +1,50 @@
"""从物料目录读取组件的可编辑字段,供动作生成理解字段含义,并供校验约束属性结构"""
from app.resources.component import ComponentProvider
from app.schemas.component_resource import ComponentDefinition, ComponentQuery
from app.schemas.screen import ScreenNode
# 属性面板的 key 以区域开头,只有这两个区域可以被 update 修改
EDITABLE_SECTIONS = ("props", "style")
def _materials(node_type: str) -> list[ComponentDefinition]:
# 多个物料预设可以共用同一前端 type,按 type 取全部预设
result = ComponentProvider().query(ComponentQuery(type=node_type))
return result.components if result.status == "resolved" else []
def _split_key(key: str) -> tuple[str, list[str | int]] | None:
# 前端点路径中的纯数字段是数组下标,转换为与 UpdateAction.path 一致的整数
section, *path = key.split(".")
if section not in EDITABLE_SECTIONS or not path:
return None
return section, [int(part) if part.isdigit() else part for part in path]
def editable_fields(node_type: str) -> list[dict]:
"""返回组件属性面板中的字段:用户可见名称、区域、路径及可选值,按出现顺序去重"""
fields: dict[str, dict] = {}
for material in _materials(node_type):
for setter in material.setters:
parsed = _split_key(setter.key)
if parsed is None or setter.key in fields:
continue
section, path = parsed
field = {"label": setter.label, "section": section, "path": path}
if options := setter.props.get("options"):
field["options"] = options
fields[setter.key] = field
return list(fields.values())
def known_prop_keys(node: ScreenNode) -> set[str] | None:
"""props 第一层是组件与渲染器的约定,只能是节点已有或物料默认配置中的字段;
目录中找不到该类型时约定未知,返回 None 表示不做限制"""
materials = _materials(node.type)
if not materials:
return None
keys = set(node.props)
for material in materials:
keys.update(material.schema_.props)
return keys
+8
View File
@@ -0,0 +1,8 @@
"""用户中心返回的当前用户,只保留本服务用到的字段"""
from pydantic import BaseModel
class CurrentUser(BaseModel):
id: int
username: str
+15 -6
View File
@@ -31,12 +31,12 @@ class AgentService:
self._active: set[str] = set() self._active: set[str] = set()
self._conversation_of: dict[str, str] = {} self._conversation_of: dict[str, str] = {}
async def prepare_run(self, request: DesignRequest): async def prepare_run(self, request: DesignRequest, user_id: int):
# 会话 ID 必须由后端签发,不再为缺失的 ID 兜底生成 # 会话 ID 必须由后端签发,不再为缺失的 ID 兜底生成
conversation_id = request.context.conversation_id conversation_id = request.context.conversation_id
if conversation_id is None: if conversation_id is None:
raise BizException(422, "请先创建会话并传入 conversation_id") raise BizException(422, "请先创建会话并传入 conversation_id")
if not await self.store.exists(conversation_id): if not await self.store.exists(conversation_id, user_id):
raise BizException(404, "会话不存在或已过期") raise BizException(404, "会话不存在或已过期")
# 新请求始终独立建任务,不用 conversation_id 充当检查点 ID # 新请求始终独立建任务,不用 conversation_id 充当检查点 ID
@@ -72,9 +72,10 @@ class AgentService:
"message_id": str(uuid4()), "message_id": str(uuid4()),
}, },
conversation_id, conversation_id,
user_id,
) )
async def prepare_resume(self, request: ResumeRequest): async def prepare_resume(self, request: ResumeRequest, user_id: int):
# 在发送流响应头之前完成校验并占用任务 # 在发送流响应头之前完成校验并占用任务
thread_id = str(request.thread_id) thread_id = str(request.thread_id)
# 检查和占用之间没有 await,同一事件循环内不会重复进入 # 检查和占用之间没有 await,同一事件循环内不会重复进入
@@ -83,7 +84,10 @@ class AgentService:
snapshot = await self.graph.aget_state( snapshot = await self.graph.aget_state(
{"configurable": {"thread_id": thread_id}} {"configurable": {"thread_id": thread_id}}
) )
if not snapshot.values: # 不属于当前用户的任务与不存在同样处理,先于其余校验,不泄露任务状态
if not snapshot.values or not await self.store.exists(
snapshot.values["conversation_id"], user_id
):
raise BizException(404, "任务不存在或已过期") raise BizException(404, "任务不存在或已过期")
if not any(task.interrupts for task in snapshot.tasks): if not any(task.interrupts for task in snapshot.tasks):
raise BizException(409, "任务当前未等待用户补充") raise BizException(409, "任务当前未等待用户补充")
@@ -119,6 +123,7 @@ class AgentService:
update={"message_id": str(uuid4())}, update={"message_id": str(uuid4())},
), ),
conversation_id, conversation_id,
user_id,
) )
except BaseException: except BaseException:
self.release(thread_id) self.release(thread_id)
@@ -196,13 +201,17 @@ class AgentService:
failure_reason=state.get("failure_reason"), failure_reason=state.get("failure_reason"),
) )
async def stream(self, thread_id, payload, session_id): async def stream(self, thread_id, payload, session_id, user_id=None):
"""消费图事件,完整状态只用于整理最终结果,不直接暴露给客户端""" """消费图事件,完整状态只用于整理最终结果,不直接暴露给客户端"""
state = None state = None
with logger.contextualize(thread_id=thread_id): with logger.contextualize(thread_id=thread_id):
async with aclosing( async with aclosing(
astream_screen( astream_screen(
self.graph, payload, thread_id=thread_id, session_id=session_id self.graph,
payload,
thread_id=thread_id,
session_id=session_id,
user_id=user_id,
) )
) as events: ) as events:
async for mode, data in events: async for mode, data in events:
+13 -5
View File
@@ -11,9 +11,11 @@ from app.schemas.conversation import Turn
class ConversationStore(Protocol): class ConversationStore(Protocol):
"""内存和 MongoDB 实现同一组方法,业务代码不需要关心具体存储""" """内存和 MongoDB 实现同一组方法,业务代码不需要关心具体存储"""
async def create(self) -> str: ... async def create(self, user_id: int) -> str: ...
async def exists(self, conversation_id: str) -> bool: ... async def exists(self, conversation_id: str, user_id: int) -> bool:
"""会话已签发、未过期且属于该用户;不属于时与不存在同样返回 False"""
...
async def load_turns( async def load_turns(
self, conversation_id: str, limit: int | None self, conversation_id: str, limit: int | None
@@ -32,6 +34,7 @@ class InMemoryConversationStore:
self._empty_ttl = timedelta(seconds=ttl) self._empty_ttl = timedelta(seconds=ttl)
# 会话 ID -> 创建时间;轮次按 thread_id 去重并保持首次写入顺序 # 会话 ID -> 创建时间;轮次按 thread_id 去重并保持首次写入顺序
self._created: dict[str, datetime] = {} self._created: dict[str, datetime] = {}
self._owner: dict[str, int] = {}
self._turns: dict[str, dict[str, Turn]] = {} self._turns: dict[str, dict[str, Turn]] = {}
def _purge_expired(self) -> None: def _purge_expired(self) -> None:
@@ -43,17 +46,22 @@ class InMemoryConversationStore:
if created < deadline and not self._turns.get(key) if created < deadline and not self._turns.get(key)
]: ]:
del self._created[conversation_id] del self._created[conversation_id]
self._owner.pop(conversation_id, None)
self._turns.pop(conversation_id, None) self._turns.pop(conversation_id, None)
async def create(self) -> str: async def create(self, user_id: int) -> str:
self._purge_expired() self._purge_expired()
conversation_id = str(uuid4()) conversation_id = str(uuid4())
self._created[conversation_id] = _now() self._created[conversation_id] = _now()
self._owner[conversation_id] = user_id
return conversation_id return conversation_id
async def exists(self, conversation_id: str) -> bool: async def exists(self, conversation_id: str, user_id: int) -> bool:
self._purge_expired() self._purge_expired()
return conversation_id in self._created return (
conversation_id in self._created
and self._owner.get(conversation_id) == user_id
)
async def load_turns(self, conversation_id: str, limit: int | None) -> list[Turn]: async def load_turns(self, conversation_id: str, limit: int | None) -> list[Turn]:
turns = list(self._turns.get(conversation_id, {}).values()) turns = list(self._turns.get(conversation_id, {}).values())
+10 -5
View File
@@ -42,6 +42,8 @@ class MongoConversationStore:
) )
# 空会话清理按创建时间扫描 # 空会话清理按创建时间扫描
await self._conversations.create_index("created_at") await self._conversations.create_index("created_at")
# 按用户查询会话
await self._conversations.create_index("user_id")
async def _purge_expired(self) -> None: async def _purge_expired(self) -> None:
# 只清理没有任何轮次且超过保留时长的空会话,有对话的会话不受影响 # 只清理没有任何轮次且超过保留时长的空会话,有对话的会话不受影响
@@ -63,17 +65,20 @@ class MongoConversationStore:
if empty: if empty:
await self._conversations.delete_many({"_id": {"$in": empty}}) await self._conversations.delete_many({"_id": {"$in": empty}})
async def create(self) -> str: async def create(self, user_id: int) -> str:
await self._purge_expired() await self._purge_expired()
conversation_id = str(uuid4()) conversation_id = str(uuid4())
# user_id 为后续长期记忆预留,当前恒为空 # user_id 来自用户中心,会话只对创建者可见
await self._conversations.insert_one( await self._conversations.insert_one(
{"_id": conversation_id, "created_at": _now(), "user_id": None} {"_id": conversation_id, "created_at": _now(), "user_id": user_id}
) )
return conversation_id return conversation_id
async def exists(self, conversation_id: str) -> bool: async def exists(self, conversation_id: str, user_id: int) -> bool:
document = await self._conversations.find_one({"_id": conversation_id}) # 不属于该用户的会话与不存在同样处理,不泄露会话是否存在
document = await self._conversations.find_one(
{"_id": conversation_id, "user_id": user_id}
)
if document is None: if document is None:
return False return False
if document["created_at"] >= _now() - self._empty_ttl: if document["created_at"] >= _now() - self._empty_ttl:
+6 -4
View File
@@ -3,7 +3,7 @@
from loguru import logger from loguru import logger
from pymongo.asynchronous.database import AsyncDatabase from pymongo.asynchronous.database import AsyncDatabase
from app.resources.component import load_catalog_file, set_catalog_cache from app.resources.component import set_catalog_cache
from app.schemas.component_resource import ComponentCatalog from app.schemas.component_resource import ComponentCatalog
# 元数据记录的 _id,组件记录的 _id 为组件 key # 元数据记录的 _id,组件记录的 _id 为组件 key
@@ -49,11 +49,13 @@ class MongoMaterialRepository:
async def warm_material_cache(db: AsyncDatabase | None) -> ComponentCatalog | None: async def warm_material_cache(db: AsyncDatabase | None) -> ComponentCatalog | None:
"""启动时把物料目录载入进程缓存;Mongo 未启用或尚未导入时回退读本地 JSON""" """启动时把物料目录从 MongoDB 载入进程缓存;未启用或尚未导入时目录为空,不回退读文件"""
catalog = await MongoMaterialRepository(db).load_all() if db is not None else None catalog = await MongoMaterialRepository(db).load_all() if db is not None else None
if catalog is None: if catalog is None:
catalog = load_catalog_file() logger.error(
logger.warning("物料未从 MongoDB 加载,回退使用本地目录文件") "物料目录未载入:{},新增、更换组件等需要物料的修改将无法执行",
"未配置 MONGODB_URI" if db is None else "materials 集合尚未导入",
)
else: else:
logger.info("已从 MongoDB 加载物料 {} 个", len(catalog.components)) logger.info("已从 MongoDB 加载物料 {} 个", len(catalog.components))
set_catalog_cache(catalog) set_catalog_cache(catalog)
+1
View File
@@ -15,4 +15,5 @@ dependencies = [
"loguru>=0.7.3", "loguru>=0.7.3",
"pymongo>=4.17.0", "pymongo>=4.17.0",
"langgraph-checkpoint-mongodb>=0.5.0", "langgraph-checkpoint-mongodb>=0.5.0",
"httpx>=0.28.1",
] ]
Generated
+2
View File
@@ -1117,6 +1117,7 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "httpx" },
{ name = "langchain" }, { name = "langchain" },
{ name = "langchain-openai" }, { name = "langchain-openai" },
{ name = "langfuse" }, { name = "langfuse" },
@@ -1133,6 +1134,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "langchain", specifier = ">=1.4.2" }, { name = "langchain", specifier = ">=1.4.2" },
{ name = "langchain-openai", specifier = ">=1.6.2" }, { name = "langchain-openai", specifier = ">=1.6.2" },
{ name = "langfuse", specifier = "==4.15.6" }, { name = "langfuse", specifier = "==4.15.6" },