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:
QihuAI Design committed 2026-07-15 13:01:19 +08:00
1 parent b0bfba5c16
commit d374d464e8
6 files changed
+209 -5

No files matched your search

+91
View File
@@ -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,
+1
View File
@@ -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)
+2
View File
@@ -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`),
+45
View File
@@ -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;
+60 -5
View File
@@ -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();
+10
View File
@@ -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;