feat: 智能Agent接入前连通性/鉴权校验
- 前端技能市场「接入单个Agent」新增「测试连接」按钮,接入前必须校验通过 - 后端 POST /market/apps/verify-agent:dify 走 chat-messages 鉴权探测(trust_env=False 直连),n8n/coze 走 HEAD 可达性探测 - upload_app 对 agent 类型做服务端守卫,密钥无效/不可达直接 400 拒绝 - MarketApp 新增 platform 列(dify/n8n/coze),009 迁移幂等加列
This commit is contained in:
1 parent
b0bfba5c16
commit
d374d464e8
6 files changed
+209
-5
No files matched your search
@@ -5,6 +5,8 @@ from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
@@ -17,6 +19,84 @@ from ..middleware.log_service import LogService
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ===== Agent 接入前连通性 / 鉴权校验 =====
|
||||
def _normalize_dify_url(endpoint: str) -> str:
|
||||
ep = endpoint.rstrip("/")
|
||||
if ep.endswith("/chat-messages"):
|
||||
return ep
|
||||
if ep.endswith("/v1/chat-messages"):
|
||||
return ep
|
||||
if ep.endswith("/v1"):
|
||||
return ep + "/chat-messages"
|
||||
return ep + "/v1/chat-messages"
|
||||
|
||||
|
||||
def _short_err(e: Exception) -> str:
|
||||
return str(e)[:160]
|
||||
|
||||
|
||||
def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dict:
|
||||
"""
|
||||
接入 Agent 前的连通性 + 鉴权校验。
|
||||
返回 {ok, verified, detail}
|
||||
ok: 是否通过(dify=鉴权通过;n8n/coze/其他=地址可达)
|
||||
verified: 是否做了真正的鉴权验证(dify 为 True,其余为 False)
|
||||
"""
|
||||
ep = (endpoint or "").strip()
|
||||
if not ep or not ep.startswith(("http://", "https://")):
|
||||
return {"ok": False, "verified": False, "detail": "Endpoint 必须以 http:// 或 https:// 开头"}
|
||||
platform = (platform or "dify").lower()
|
||||
|
||||
# 关键:容器内 env 有坏代理(502),必须 trust_env=False 直连
|
||||
if platform == "dify":
|
||||
url = _normalize_dify_url(ep)
|
||||
payload = {
|
||||
"inputs": {},
|
||||
"query": "连通性测试",
|
||||
"user": "__verify__",
|
||||
"response_mode": "blocking",
|
||||
}
|
||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||
try:
|
||||
r = httpx.post(url, headers=headers, json=payload, timeout=15.0, trust_env=False)
|
||||
except Exception as e:
|
||||
return {"ok": False, "verified": False, "detail": f"无法连接 Dify 地址:{_short_err(e)}"}
|
||||
if r.status_code == 200:
|
||||
return {"ok": True, "verified": True, "detail": "连通且 API Key 有效"}
|
||||
if r.status_code in (401, 403):
|
||||
return {"ok": False, "verified": False, "detail": "API Key 无效(401/403)"}
|
||||
if r.status_code == 404:
|
||||
return {"ok": False, "verified": False,
|
||||
"detail": "地址路径不正确(404),应为 .../v1/chat-messages"}
|
||||
return {"ok": False, "verified": False, "detail": f"Dify 返回状态码 {r.status_code}"}
|
||||
|
||||
# n8n / coze / 其他:仅做可达性探测(HEAD,避免触发 webhook 副作用)
|
||||
try:
|
||||
r = httpx.head(ep, timeout=12.0, trust_env=False, follow_redirects=True)
|
||||
if r.status_code < 500:
|
||||
return {"ok": True, "verified": False,
|
||||
"detail": f"地址可连通(HTTP {r.status_code}),运行时再做协议级鉴权"}
|
||||
return {"ok": False, "verified": False, "detail": f"服务返回 {r.status_code},地址可能不可用"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "verified": False, "detail": f"地址不可达:{_short_err(e)}"}
|
||||
|
||||
|
||||
class VerifyAgentRequest(BaseModel):
|
||||
platform: Optional[str] = None
|
||||
endpoint: str
|
||||
api_key: str
|
||||
|
||||
|
||||
@router.post("/apps/verify-agent")
|
||||
def verify_agent(
|
||||
req: VerifyAgentRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""接入 Agent 前手动测试连通性 / 鉴权,供前台「测试连接」按钮调用。"""
|
||||
result = verify_agent_connectivity(req.platform or "dify", req.endpoint, req.api_key)
|
||||
return {"code": 0, "data": result, "message": "ok"}
|
||||
|
||||
|
||||
@router.get("/ads")
|
||||
def list_public_ads(db: Session = Depends(get_db)):
|
||||
"""
|
||||
@@ -59,6 +139,7 @@ class UploadAppRequest(BaseModel):
|
||||
join_promotion: Optional[bool] = False
|
||||
agent_endpoint: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
platform: Optional[str] = None
|
||||
|
||||
|
||||
class PurchaseAppRequest(BaseModel):
|
||||
@@ -209,6 +290,15 @@ def upload_app(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
# 接入 Agent 时先做连通性 / 鉴权校验,避免把无效连接存进库
|
||||
if (req.type or "") == "agent" and req.agent_endpoint and req.api_key:
|
||||
v = verify_agent_connectivity(req.platform or "dify", req.agent_endpoint, req.api_key)
|
||||
if not v["ok"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Agent 连接校验未通过:{v['detail']}",
|
||||
)
|
||||
|
||||
app = MarketApp(
|
||||
id=gen_uuid(),
|
||||
name=req.name,
|
||||
@@ -216,6 +306,7 @@ def upload_app(
|
||||
icon=req.icon,
|
||||
type=req.type if req.type in ("skill","agent","mcp") else "skill",
|
||||
source=req.source if req.source in ("official","user") else "user",
|
||||
platform=req.platform,
|
||||
price=req.price,
|
||||
install_count=0,
|
||||
rating_avg=0,
|
||||
|
||||
@@ -294,6 +294,7 @@ class MarketApp(Base):
|
||||
install_count = Column(Integer, default=0)
|
||||
skill_content = Column(Text, default=None)
|
||||
mcp_transport = Column(String(20), default=None)
|
||||
platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze")
|
||||
agent_endpoint = Column(String(500), default=None)
|
||||
agent_api_key = Column(String(500), default=None, comment="单个Agent接入的API Key")
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
@@ -313,6 +313,8 @@ export const marketAPI = {
|
||||
|
||||
createApp: (data: any) => post<any>('/market/apps', data),
|
||||
|
||||
verifyAgent: (data: any) => post<any>('/market/apps/verify-agent', data),
|
||||
|
||||
purchaseApp: (id: string) => post<any>(`/market/apps/${id}/purchase`),
|
||||
|
||||
installApp: (id: string) => post<any>(`/market/apps/${id}/install`),
|
||||
|
||||
@@ -856,6 +856,51 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Agent 接入:连通性 / 鉴权校验 */
|
||||
.agent-verify-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-test-btn {
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
background: transparent;
|
||||
color: var(--color-accent);
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.form-test-btn:hover:not(:disabled) {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.form-test-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.agent-verify-msg {
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.agent-verify-msg.ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.agent-verify-msg.err {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* Audit Progress */
|
||||
.audit-progress {
|
||||
display: flex;
|
||||
|
||||
@@ -1172,14 +1172,53 @@ const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnec
|
||||
const [endpoint, setEndpoint] = useState('');
|
||||
const [price, setPrice] = useState<number>(0);
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [verifyMsg, setVerifyMsg] = useState<string>('');
|
||||
const [verifyOk, setVerifyOk] = useState<boolean | null>(null);
|
||||
|
||||
const handleConnect = () => {
|
||||
const runVerify = async (): Promise<boolean | null> => {
|
||||
if (!endpoint.trim() || !apiKey.trim()) {
|
||||
setVerifyMsg('请先填写 API Endpoint 与 API Key');
|
||||
setVerifyOk(false);
|
||||
return false;
|
||||
}
|
||||
setVerifying(true);
|
||||
setVerifyMsg('正在校验连通性与密钥...');
|
||||
setVerifyOk(null);
|
||||
try {
|
||||
const res: any = await marketAPI.verifyAgent({
|
||||
platform,
|
||||
endpoint: endpoint.trim(),
|
||||
api_key: apiKey.trim(),
|
||||
});
|
||||
if (res && res.code === 0 && res.data && res.data.ok) {
|
||||
setVerifyOk(true);
|
||||
setVerifyMsg('✓ ' + (res.data.detail || '校验通过'));
|
||||
return true;
|
||||
}
|
||||
setVerifyOk(false);
|
||||
setVerifyMsg('✗ ' + ((res && res.data && res.data.detail) || '校验未通过'));
|
||||
return false;
|
||||
} catch (e: any) {
|
||||
setVerifyOk(false);
|
||||
setVerifyMsg('✗ 校验请求失败:' + (e?.message || '网络错误'));
|
||||
return false;
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!name.trim() || !endpoint.trim() || !apiKey.trim()) return;
|
||||
// 接入前必须先通过连通性 / 鉴权校验
|
||||
const ok = await runVerify();
|
||||
if (!ok) return;
|
||||
setConnecting(true);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
await onConnect(platform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price, name.trim(), intro.trim());
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
onConnect(platform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price, name.trim(), intro.trim());
|
||||
}, 800);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1254,10 +1293,25 @@ const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnec
|
||||
/>
|
||||
<span className="hub-price-hint">用户对接的 Agent 是否收费由您决定;管理员导入默认免费且审核通过。</span>
|
||||
</div>
|
||||
<div className="form-section agent-verify-row">
|
||||
<button
|
||||
type="button"
|
||||
className="form-test-btn"
|
||||
onClick={runVerify}
|
||||
disabled={!endpoint.trim() || !apiKey.trim() || verifying}
|
||||
>
|
||||
{verifying ? '校验中...' : '测试连接'}
|
||||
</button>
|
||||
{verifyMsg && (
|
||||
<span className={`agent-verify-msg ${verifyOk === true ? 'ok' : verifyOk === false ? 'err' : ''}`}>
|
||||
{verifyMsg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="form-submit-btn"
|
||||
onClick={handleConnect}
|
||||
disabled={!name.trim() || !endpoint.trim() || !apiKey.trim() || connecting}
|
||||
disabled={!name.trim() || !endpoint.trim() || !apiKey.trim() || connecting || verifying}
|
||||
>
|
||||
{connecting ? '接入中...' : '接入 Agent'}
|
||||
</button>
|
||||
@@ -1393,6 +1447,7 @@ const MarketPage: React.FC = () => {
|
||||
source: 'user',
|
||||
agent_endpoint: config.endpoint || '',
|
||||
api_key: config.apiKey || '',
|
||||
platform: platformId || 'dify',
|
||||
});
|
||||
// 重新加载列表
|
||||
loadApps();
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 009: 为 market_apps 增加 platform 列(Agent 接入平台 dify/n8n/coze)
|
||||
-- 幂等:列已存在则跳过
|
||||
SET @col = (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'market_apps' AND column_name = 'platform');
|
||||
SET @sql = IF(@col = 0,
|
||||
'ALTER TABLE market_apps ADD COLUMN platform VARCHAR(20) NULL COMMENT \'Agent 接入平台:dify/n8n/coze\'',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
Reference in new issue
Block a user