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
|
from typing import Optional
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import httpx
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import (
|
from ..models import (
|
||||||
@@ -17,6 +19,84 @@ from ..middleware.log_service import LogService
|
|||||||
router = APIRouter()
|
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")
|
@router.get("/ads")
|
||||||
def list_public_ads(db: Session = Depends(get_db)):
|
def list_public_ads(db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
@@ -59,6 +139,7 @@ class UploadAppRequest(BaseModel):
|
|||||||
join_promotion: Optional[bool] = False
|
join_promotion: Optional[bool] = False
|
||||||
agent_endpoint: Optional[str] = None
|
agent_endpoint: Optional[str] = None
|
||||||
api_key: Optional[str] = None
|
api_key: Optional[str] = None
|
||||||
|
platform: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class PurchaseAppRequest(BaseModel):
|
class PurchaseAppRequest(BaseModel):
|
||||||
@@ -209,6 +290,15 @@ def upload_app(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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(
|
app = MarketApp(
|
||||||
id=gen_uuid(),
|
id=gen_uuid(),
|
||||||
name=req.name,
|
name=req.name,
|
||||||
@@ -216,6 +306,7 @@ def upload_app(
|
|||||||
icon=req.icon,
|
icon=req.icon,
|
||||||
type=req.type if req.type in ("skill","agent","mcp") else "skill",
|
type=req.type if req.type in ("skill","agent","mcp") else "skill",
|
||||||
source=req.source if req.source in ("official","user") else "user",
|
source=req.source if req.source in ("official","user") else "user",
|
||||||
|
platform=req.platform,
|
||||||
price=req.price,
|
price=req.price,
|
||||||
install_count=0,
|
install_count=0,
|
||||||
rating_avg=0,
|
rating_avg=0,
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ class MarketApp(Base):
|
|||||||
install_count = Column(Integer, default=0)
|
install_count = Column(Integer, default=0)
|
||||||
skill_content = Column(Text, default=None)
|
skill_content = Column(Text, default=None)
|
||||||
mcp_transport = Column(String(20), 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_endpoint = Column(String(500), default=None)
|
||||||
agent_api_key = Column(String(500), default=None, comment="单个Agent接入的API Key")
|
agent_api_key = Column(String(500), default=None, comment="单个Agent接入的API Key")
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
|||||||
@@ -313,6 +313,8 @@ export const marketAPI = {
|
|||||||
|
|
||||||
createApp: (data: any) => post<any>('/market/apps', data),
|
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`),
|
purchaseApp: (id: string) => post<any>(`/market/apps/${id}/purchase`),
|
||||||
|
|
||||||
installApp: (id: string) => post<any>(`/market/apps/${id}/install`),
|
installApp: (id: string) => post<any>(`/market/apps/${id}/install`),
|
||||||
|
|||||||
@@ -856,6 +856,51 @@
|
|||||||
cursor: not-allowed;
|
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 */
|
||||||
.audit-progress {
|
.audit-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1172,14 +1172,53 @@ const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnec
|
|||||||
const [endpoint, setEndpoint] = useState('');
|
const [endpoint, setEndpoint] = useState('');
|
||||||
const [price, setPrice] = useState<number>(0);
|
const [price, setPrice] = useState<number>(0);
|
||||||
const [connecting, setConnecting] = useState(false);
|
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;
|
if (!name.trim() || !endpoint.trim() || !apiKey.trim()) return;
|
||||||
|
// 接入前必须先通过连通性 / 鉴权校验
|
||||||
|
const ok = await runVerify();
|
||||||
|
if (!ok) return;
|
||||||
setConnecting(true);
|
setConnecting(true);
|
||||||
setTimeout(() => {
|
try {
|
||||||
|
await onConnect(platform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price, name.trim(), intro.trim());
|
||||||
|
} finally {
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
onConnect(platform, { apiKey: apiKey.trim(), endpoint: endpoint.trim() }, price, name.trim(), intro.trim());
|
}
|
||||||
}, 800);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1254,10 +1293,25 @@ const AgentConnectModal: React.FC<AgentConnectModalProps> = ({ onClose, onConnec
|
|||||||
/>
|
/>
|
||||||
<span className="hub-price-hint">用户对接的 Agent 是否收费由您决定;管理员导入默认免费且审核通过。</span>
|
<span className="hub-price-hint">用户对接的 Agent 是否收费由您决定;管理员导入默认免费且审核通过。</span>
|
||||||
</div>
|
</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
|
<button
|
||||||
className="form-submit-btn"
|
className="form-submit-btn"
|
||||||
onClick={handleConnect}
|
onClick={handleConnect}
|
||||||
disabled={!name.trim() || !endpoint.trim() || !apiKey.trim() || connecting}
|
disabled={!name.trim() || !endpoint.trim() || !apiKey.trim() || connecting || verifying}
|
||||||
>
|
>
|
||||||
{connecting ? '接入中...' : '接入 Agent'}
|
{connecting ? '接入中...' : '接入 Agent'}
|
||||||
</button>
|
</button>
|
||||||
@@ -1393,6 +1447,7 @@ const MarketPage: React.FC = () => {
|
|||||||
source: 'user',
|
source: 'user',
|
||||||
agent_endpoint: config.endpoint || '',
|
agent_endpoint: config.endpoint || '',
|
||||||
api_key: config.apiKey || '',
|
api_key: config.apiKey || '',
|
||||||
|
platform: platformId || 'dify',
|
||||||
});
|
});
|
||||||
// 重新加载列表
|
// 重新加载列表
|
||||||
loadApps();
|
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