Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 79 additions & 8 deletions backend/app/core/middlewares.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
import uuid
from typing import Any

from starlette.datastructures import Headers
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.requests import Request
from starlette.responses import RedirectResponse, Response
from starlette.types import ASGIApp
from starlette.responses import PlainTextResponse, RedirectResponse, Response
from starlette.types import ASGIApp, Receive, Scope, Send

from app.common.enums import RedisInitKeyConfig, SysParamKey
from app.common.response import ErrorResponse
Expand Down Expand Up @@ -126,11 +126,82 @@ async def dispatch(self, request: Request, call_next):
return await call_next(request)


class CustomTrustedHostMiddleware(TrustedHostMiddleware):
"""可信主机 Host 头校验中间件"""

def __init__(self, app: ASGIApp) -> None:
super().__init__(app, allowed_hosts=settings.ALLOWED_HOSTS)
def host_of(value: str) -> str:
"""从 Host 头里取出主机部分(小写、去端口)。

修正 Starlette 的 ``host.split(":")[0]`` 在 IPv6 下的误判:
``127.0.0.1:8000`` → ``127.0.0.1``;``[::1]:8000`` → ``::1``;裸 ``::1`` → ``::1``。
"""
v = (value or "").strip().lower()
if v.startswith("["):
end = v.find("]")
return v[1:end] if end != -1 else v
# 只有「主机:端口」这一种情况该剥;裸 IPv6 里的多个冒号不是端口分隔符
return v.rsplit(":", 1)[0] if v.count(":") == 1 else v


class CustomTrustedHostMiddleware:
"""可信主机 Host 头校验中间件(纯 ASGI 实现)。

在 Starlette ``TrustedHostMiddleware`` 基础上修正两处,并保持其余语义不变:
1. **IPv6 字面量**:Starlette 用 ``host.split(":")[0]``,``[::1]:8000`` 会被切成 ``"["``,
导致 ``::1`` 永远校验失败(本地开 ``ALLOW_LOCALHOST_HOSTS`` 时用 IPv6 访问会 400);
2. **大小写**:DNS 主机名不区分大小写,主机与允许清单统一转小写后比较。

其余行为照旧:``*`` 放行全部;支持 ``*.example.com`` 形式通配;``www.`` 前缀可 301 重定向;
允许清单里的条目也做同样归一(``[::1]`` 与 ``[::1]:8000`` 都按 ``::1`` 处理)。
"""

app: ASGIApp
allowed_hosts: list[str]
allow_any: bool
www_redirect: bool

def __init__(
self,
app: ASGIApp,
allowed_hosts: list[str] | None = None,
www_redirect: bool = True,
) -> None:
hosts = allowed_hosts if allowed_hosts is not None else settings.ALLOWED_HOSTS
normalized: list[str] = []
for pattern in hosts:
assert "*" not in pattern[1:], "通配符只允许出现在最左侧(如 *.example.com)"
if pattern.startswith("*") and pattern != "*":
assert pattern.startswith("*."), "通配符只允许 *.example.com 形式"
normalized.append(host_of(pattern))
self.app = app
self.allowed_hosts = normalized
self.allow_any = "*" in normalized
self.www_redirect = www_redirect

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if self.allow_any or scope["type"] not in ("http", "websocket"):
await self.app(scope, receive, send)
return

host = host_of(Headers(scope=scope).get("host", ""))
found_www_redirect = False
for pattern in self.allowed_hosts:
if host == pattern or (pattern.startswith("*") and host.endswith(pattern[1:])):
await self.app(scope, receive, send)
return
if "www." + host == pattern:
found_www_redirect = True

if scope["type"] == "websocket":
# WS 上不能发 HTTP 响应:按「策略违规」关闭连接
await send({"type": "websocket.close", "code": 1008})
return
response: Response
if found_www_redirect and self.www_redirect:
url = f"{scope.get('scheme', 'http')}://www.{host}"
path = scope.get("path", "/")
query = scope.get("query_string", b"").decode()
response = RedirectResponse(f"{url}{path}" + (f"?{query}" if query else ""), 301)
else:
response = PlainTextResponse("Invalid host header", 400)
await response(scope, receive, send)


class CorrelationIdMiddleware(BaseHTTPMiddleware):
Expand Down
38 changes: 37 additions & 1 deletion backend/app/modules/ai/chat/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,23 @@
AiChatResponseSchema,
AiModelConfigListResponse,
AiModelConfigSchema,
AiModelOptionSchema,
AiModelProbeSchema,
AiModelTestResultSchema,
AiModelTestSchema,
ChatQuerySchema,
ChatSessionCreateSchema,
ChatSessionOutSchema,
ChatSessionQueryParam,
ChatSessionUpdateSchema,
)
from .service import AiModelConfigService, ChatService, get_user_model_config
from .service import (
AiModelConfigService,
ChatService,
get_user_model_config,
probe_available_models,
probe_model_connection,
)

ChatRouter = APIRouter(route_class=OperationLogRoute, prefix="/chat", tags=["AI管理"])

Expand Down Expand Up @@ -178,6 +188,32 @@ async def activate_model_config_controller(
return SuccessResponse(data=None, msg="已切换模型")


@ChatRouter.post(
"/model/available",
summary="拉取接口可用模型列表(按填写中的接口与密钥,不落库)",
response_model=ResponseSchema[list[AiModelOptionSchema]],
)
async def list_available_models_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_ai:chat:update"]))],
data: Annotated[AiModelProbeSchema, Body(description="接口地址与密钥")],
) -> JSONResponse:
result = await probe_available_models(data.base_url, data.api_key)
return SuccessResponse(data=result, msg="拉取模型列表成功")


@ChatRouter.post(
"/model/test",
summary="测试 AI 接口连通性(按填写中的参数探测,不落库)",
response_model=ResponseSchema[AiModelTestResultSchema],
)
async def test_model_connection_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_ai:chat:update"]))],
data: Annotated[AiModelTestSchema, Body(description="接口地址、密钥与模型 ID")],
) -> JSONResponse:
result = await probe_model_connection(data.base_url, data.api_key, data.model_id)
return SuccessResponse(data=result, msg="连通性测试通过")


async def _send_error_and_close(websocket: WebSocket, message: str) -> None:
"""发送错误消息并关闭连接"""
try:
Expand Down
48 changes: 43 additions & 5 deletions backend/app/modules/ai/chat/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,11 @@ class AiChatResponseSchema(BaseModel):
action: dict[str, Any] | None = Field(None, description="建议执行的操作")


class AiModelConfigSchema(BaseModel):
"""AI 模型配置项(创建/更新共用)"""
class AiModelEndpointSchema(BaseModel):
"""OpenAI 兼容接口的接入信息(探测类请求共用:不落库)"""

name: str = Field(..., min_length=1, max_length=50, description="配置名称(用户可读)")
base_url: str = Field(..., min_length=1, max_length=500, description="API Base URL,如 https://api.openai.com/v1")
api_key: str = Field(..., min_length=1, max_length=500, description="API 密钥")
model_id: str = Field(..., min_length=1, max_length=100, description="模型 ID")
temperature: float = Field(0.7, ge=0.0, le=2.0, description="温度参数")

@field_validator("base_url")
@classmethod
Expand All @@ -106,6 +103,47 @@ def validate_base_url(cls, v: str) -> str:
raise ValueError("Base URL 必须以 http:// 或 https:// 开头")
return v


class AiModelProbeSchema(AiModelEndpointSchema):
"""拉取模型列表的入参(按填写中的接口与密钥探测,不落库)"""


class AiModelTestSchema(AiModelEndpointSchema):
"""连通性测试入参(按填写中的接口/密钥/模型探测,不落库)"""

model_id: str = Field(..., min_length=1, max_length=100, description="模型 ID")

@field_validator("model_id")
@classmethod
def validate_model_id(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("模型 ID 不能为空")
return v


class AiModelOptionSchema(BaseModel):
"""接口返回的可用模型"""

id: str = Field(..., description="模型 ID")
owned_by: str | None = Field(default=None, description="提供方(接口返回时才有)")


class AiModelTestResultSchema(BaseModel):
"""连通性测试结果"""

ok: bool = Field(..., description="是否连通")
model_id: str = Field(..., description="被测模型 ID")
latency_ms: int = Field(..., description="往返耗时(毫秒)")


class AiModelConfigSchema(AiModelEndpointSchema):
"""AI 模型配置项(创建/更新共用)"""

name: str = Field(..., min_length=1, max_length=50, description="配置名称(用户可读)")
model_id: str = Field(..., min_length=1, max_length=100, description="模型 ID")
temperature: float = Field(0.7, ge=0.0, le=2.0, description="温度参数")

@field_validator("model_id")
@classmethod
def validate_model_id(cls, v: str) -> str:
Expand Down
63 changes: 63 additions & 0 deletions backend/app/modules/ai/chat/service.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import asyncio
import json
import time
import uuid
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Any

import httpx
from agno.run.team import TeamRunOutput
from agno.session.team import TeamSession
from redis.asyncio import Redis
Expand Down Expand Up @@ -530,3 +532,64 @@ async def set_active(self, config_id: str) -> None:
ok = await set_active_model_config(self.redis, self._user_id, config_id)
if not ok:
raise CustomException(msg="模型配置不存在", code=RET.NOT_FOUND.code)


# ── OpenAI 兼容接口探测:拉取模型列表 / 连通性测试(不落库)───────────────
# 只用于「按填写中的接口与密钥」做即时探测,配置落库仍走上面的 AiModelConfigService。
# 安全:base_url 已由 schema 校验为 http(s);仅取结果的模型 id / owned_by,不回显密钥。

_AI_PROBE_TIMEOUT = 20.0


def _ai_auth_headers(api_key: str) -> dict[str, str]:
"""构造 Bearer 鉴权头(密钥只放进请求头,不落日志)。"""
return {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}


async def probe_available_models(base_url: str, api_key: str) -> list[dict[str, Any]]:
"""拉取接口可用的模型列表(GET {base_url}/models)。

返回:
- list[dict[str, Any]]: [{id, owned_by}]

异常:
- CustomException: 接口不可达或返回非 2xx
"""
url = f"{base_url.rstrip('/')}/models"
try:
async with httpx.AsyncClient(timeout=_AI_PROBE_TIMEOUT) as client:
response = await client.get(url, headers=_ai_auth_headers(api_key))
except httpx.HTTPError as e:
raise CustomException(msg=f"请求模型列表失败: {type(e).__name__}") from e
if response.status_code >= 400:
raise CustomException(msg=f"请求模型列表失败: HTTP {response.status_code}")
payload: dict[str, Any] = response.json() or {}
items = payload.get("data") or []
return [
{"id": str(item.get("id")), "owned_by": item.get("owned_by")}
for item in items
if isinstance(item, dict) and item.get("id")
]


async def probe_model_connection(base_url: str, api_key: str, model_id: str) -> dict[str, Any]:
"""连通性测试:发一次最小 chat/completions 请求(max_tokens=1)。

返回:
- dict[str, Any]: {ok, model_id, latency_ms}

异常:
- CustomException: 接口不可达或返回非 2xx
"""
url = f"{base_url.rstrip('/')}/chat/completions"
body = {"model": model_id, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}
started = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=_AI_PROBE_TIMEOUT) as client:
response = await client.post(url, headers=_ai_auth_headers(api_key), json=body)
except httpx.HTTPError as e:
raise CustomException(msg=f"连通性测试失败: {type(e).__name__}") from e
latency_ms = int((time.perf_counter() - started) * 1000)
if response.status_code >= 400:
raise CustomException(msg=f"连通性测试失败: HTTP {response.status_code}")
return {"ok": True, "model_id": model_id, "latency_ms": latency_ms}
11 changes: 11 additions & 0 deletions backend/app/modules/task/storage/browse/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,17 @@ async def list_storage_buckets_controller(
return SuccessResponse(data=result, msg="查询桶列表成功")


@StorageBrowseRouter.post("/create-bucket", summary="创建存储桶(对象存储)", response_model=ResponseSchema[None])
async def create_storage_bucket_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:browse:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
bucket: Annotated[str, Body(description="桶名(COS 需带 -appid 后缀)")],
source_id: Annotated[int | None, Body(description="存储源ID(不传使用默认存储源)")] = None,
) -> JSONResponse:
await StorageFileService(auth, db).create_bucket(source_id=source_id, bucket=bucket)
return SuccessResponse(msg="创建桶成功")


@StorageBrowseRouter.post("/copy", summary="复制/移动文件", response_model=ResponseSchema[StoragePathResultSchema])
async def copy_or_move_storage_file_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:browse:update"]))],
Expand Down
22 changes: 22 additions & 0 deletions backend/app/modules/task/storage/browse/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,28 @@ async def list_buckets(self, source_id: int | None) -> list[str]:
finally:
await adapter.close()

async def create_bucket(self, source_id: int | None, bucket: str) -> None:
"""创建存储桶(仅对象存储协议)。

桶名只做与协议无关的通用校验(非空/长度/非法字符);各协议的命名细则
(如 COS 的 ``-appid`` 后缀、S3 的区域约束)交给 SDK 报错。
"""
name = (bucket or "").strip()
if not name:
raise CustomException(msg="桶名不能为空")
if len(name) > 63:
raise CustomException(msg="桶名长度不能超过 63 个字符")
if any(ch in name for ch in ("/", "\\", " ", "\t", "\n")):
raise CustomException(msg="桶名不能包含空格或斜杠")
config = await self._get_source(source_id)
if config.protocol not in _OBJECT_STORE_PROTOCOLS:
raise CustomException(msg="仅对象存储协议(S3/OSS/COS/OBS)支持创建桶")
adapter = StorageAdapterFactory.create(config)
try:
await adapter.create_bucket(name)
finally:
await adapter.close()

async def copy_or_move(
self,
source_id: int | None,
Expand Down
8 changes: 8 additions & 0 deletions backend/app/modules/task/storage/core/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ def _sync_list_buckets(self) -> list[str]:
"""列出账号下全部存储桶(对象存储专用)。FTP/SFTP/LOCAL 等协议无需实现。"""
raise NotImplementedError(f"{type(self).__name__} 不支持桶列表")

def _sync_create_bucket(self, bucket: str) -> None:
"""创建存储桶(对象存储专用)。FTP/SFTP/LOCAL 等协议无需实现。"""
raise NotImplementedError(f"{type(self).__name__} 不支持创建桶")

def _multipart_settings(self) -> tuple[int, int, bool]:
"""返回 (分片大小, 并发数, 是否分片),分片大小单位字节;并发按内存预算护栏收敛。

Expand Down Expand Up @@ -430,6 +434,10 @@ async def list_buckets(self) -> list[str]:
"""列出账号下全部存储桶(对象存储专用)。"""
return await asyncio.to_thread(self._sync_list_buckets)

async def create_bucket(self, bucket: str) -> None:
"""创建存储桶(对象存储专用;桶名规则由各协议校验)。"""
await asyncio.to_thread(self._sync_create_bucket, bucket)

async def list_recursive(self, prefix: str = "") -> list[StorageObject]:
"""递归列出目录树(含隐含目录条目)。"""
return await asyncio.to_thread(self._sync_list_recursive, prefix)
Expand Down
Loading
Loading