feat: 新增环境配置管理和接口编辑增强

- 新增环境配置(EnvironmentConfig)的增删改查和激活功能
- API 接口支持 description、request_params、response_params 字段
- 接口管理页面支持抽屉内编辑(替代跳转编辑页)
- 新增版本管理页面和工作流列表页面
- catalog_snapshot 返回 environments 数据

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-25 15:44:21 +08:00
co-authored by Cursor
parent 32cfb20999
commit 3ca520e398
9 changed files with 1087 additions and 38 deletions
+218 -1
View File
@@ -25,6 +25,7 @@ from sqlalchemy.orm import Session
from .database import Base, SessionLocal, engine, get_db
from .models import (
ApiDefinition,
EnvironmentConfig,
FolderEntry,
McpToolConfig,
MockDataset,
@@ -77,6 +78,9 @@ from .schemas import (
SshShortcutOut,
SshShortcutUpdate,
RunWorkflowRequest,
EnvironmentConfigCreate,
EnvironmentConfigOut,
EnvironmentConfigUpdate,
UserApiKeyCreate,
UserApiKeyCreateResponse,
UserApiKeyOut,
@@ -287,6 +291,9 @@ MCP_MUTATION_TOOLS = frozenset(
"ssh_script_upsert",
"workflow_batch_create",
"workflow_batch_update",
"env_upsert",
"env_activate",
"env_delete",
}
)
@@ -614,7 +621,7 @@ def _delete_ssh_script_files(row: SshScript) -> None:
MCP_TOOL_SPECS = [
{
"name": "api_upsert",
"description": "Create or update one API definition.",
"description": "Create or update one API definition (supports description and request/response param schema).",
"input_schema": {
"type": "object",
"required": ["name", "method", "url"],
@@ -624,11 +631,38 @@ MCP_TOOL_SPECS = [
"folder_path": {"type": "string"},
"method": {"type": "string"},
"url": {"type": "string"},
"description": {"type": "string", "description": "API description / notes"},
"headers": {"type": "object"},
"body": {"type": "object"},
"query": {"type": "object"},
"path_params": {"type": "object"},
"timeout_seconds": {"type": "number"},
"request_params": {
"type": "array",
"description": "Request parameter schema",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"type": {"type": "string", "enum": ["string", "integer", "number", "boolean", "object", "array"]},
"required": {"type": "boolean"},
"description": {"type": "string"},
},
},
},
"response_params": {
"type": "array",
"description": "Response parameter schema",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"type": {"type": "string", "enum": ["string", "integer", "number", "boolean", "object", "array"]},
"required": {"type": "boolean"},
"description": {"type": "string"},
},
},
},
},
},
},
@@ -966,6 +1000,44 @@ MCP_TOOL_SPECS = [
},
},
},
{
"name": "env_list",
"description": "List all environment configurations (base_url + auth_token versions).",
"input_schema": {"type": "object", "properties": {}},
},
{
"name": "env_upsert",
"description": "Create or update an environment configuration (base_url, auth_token). Requires sto- API Key.",
"input_schema": {
"type": "object",
"required": ["name", "base_url"],
"properties": {
"env_id": {"type": "integer", "description": "Pass to update existing; omit to create new"},
"name": {"type": "string"},
"base_url": {"type": "string"},
"auth_token": {"type": "string"},
"description": {"type": "string"},
},
},
},
{
"name": "env_activate",
"description": "Set an environment as the active version. Archives the previously active one. Requires sto- API Key.",
"input_schema": {
"type": "object",
"required": ["env_id"],
"properties": {"env_id": {"type": "integer"}},
},
},
{
"name": "env_delete",
"description": "Delete an environment configuration. Requires sto- API Key.",
"input_schema": {
"type": "object",
"required": ["env_id"],
"properties": {"env_id": {"type": "integer"}},
},
},
]
@@ -1968,6 +2040,7 @@ async def _dispatch_mcp_tool(
if not _is_superadmin(actor):
batch_query = batch_query.filter(WorkflowBatch.creator_id == actor.id)
batch_rows = batch_query.order_by(WorkflowBatch.id.desc()).limit(50).all()
env_rows = _scope_query_by_user(db.query(EnvironmentConfig), EnvironmentConfig, actor).order_by(EnvironmentConfig.id.desc()).all()
data = {
"apis": apis,
"workflows": workflows,
@@ -1975,6 +2048,7 @@ async def _dispatch_mcp_tool(
"workflow_batches": [_model_to_batch_dict(row) for row in batch_rows],
"mcp_tools": configs,
"ssh_tree": ssh_tree,
"environments": [EnvironmentConfigOut.model_validate(row).model_dump() for row in env_rows],
"folders": {
"apis": list_folders(target="apis", db=db, user=actor)["folders"],
"workflows": list_folders(target="workflows", db=db, user=actor)["folders"],
@@ -2055,6 +2129,67 @@ async def _dispatch_mcp_tool(
data = await run_script_collect(profile, script, password, timeout_seconds=timeout_seconds)
return McpInvokeResponse(ok=bool(data.get("ok")), tool=tool, data=data)
if tool == "env_list":
rows = _scope_query_by_user(db.query(EnvironmentConfig), EnvironmentConfig, actor).order_by(EnvironmentConfig.id.desc()).all()
items = [EnvironmentConfigOut.model_validate(row).model_dump() for row in rows]
return McpInvokeResponse(ok=True, tool=tool, data={"items": items})
if tool == "env_upsert":
env_id = args.get("env_id")
if env_id:
row = db.query(EnvironmentConfig).filter(EnvironmentConfig.id == int(env_id)).first()
if not row:
raise HTTPException(status_code=404, detail="environment not found")
_assert_owner_or_superadmin(actor, row)
row.name = str(args.get("name", row.name)).strip()
row.base_url = str(args.get("base_url", row.base_url)).strip()
row.auth_token = str(args.get("auth_token", row.auth_token)).strip()
row.description = str(args.get("description", row.description)).strip()
db.commit()
db.refresh(row)
else:
row = EnvironmentConfig(
name=str(args["name"]).strip(),
base_url=str(args.get("base_url", "")).strip(),
auth_token=str(args.get("auth_token", "")).strip(),
description=str(args.get("description", "")).strip(),
status="draft",
creator_id=actor.id,
creator_name=actor.display_name or actor.username,
)
db.add(row)
db.commit()
db.refresh(row)
data = EnvironmentConfigOut.model_validate(row).model_dump()
return McpInvokeResponse(ok=True, tool=tool, data=data)
if tool == "env_activate":
env_id = int(args["env_id"])
row = db.query(EnvironmentConfig).filter(EnvironmentConfig.id == env_id).first()
if not row:
raise HTTPException(status_code=404, detail="environment not found")
_assert_owner_or_superadmin(actor, row)
active_rows = _scope_query_by_user(
db.query(EnvironmentConfig).filter(EnvironmentConfig.status == "active"), EnvironmentConfig, actor
).all()
for active_row in active_rows:
active_row.status = "archived"
row.status = "active"
db.commit()
db.refresh(row)
data = EnvironmentConfigOut.model_validate(row).model_dump()
return McpInvokeResponse(ok=True, tool=tool, data=data)
if tool == "env_delete":
env_id = int(args["env_id"])
row = db.query(EnvironmentConfig).filter(EnvironmentConfig.id == env_id).first()
if not row:
raise HTTPException(status_code=404, detail="environment not found")
_assert_owner_or_superadmin(actor, row)
db.delete(row)
db.commit()
return McpInvokeResponse(ok=True, tool=tool, data={"deleted": env_id})
raise HTTPException(status_code=404, detail=f"unknown mcp tool: {tool}")
except HTTPException as exc:
return McpInvokeResponse(ok=False, tool=tool, error=str(exc.detail))
@@ -2386,6 +2521,9 @@ def create_api(payload: ApiCreate, db: Session = Depends(get_db), user: User = D
"query": payload.query,
"path_params": payload.path_params,
"timeout_seconds": payload.timeout_seconds,
"description": payload.description,
"request_params": [p.model_dump() for p in payload.request_params],
"response_params": [p.model_dump() for p in payload.response_params],
}
row = ApiDefinition(
name=payload.name,
@@ -2426,6 +2564,9 @@ def update_api(api_id: int, payload: ApiUpdate, db: Session = Depends(get_db), u
"query": payload.query,
"path_params": payload.path_params,
"timeout_seconds": payload.timeout_seconds,
"description": payload.description,
"request_params": [p.model_dump() for p in payload.request_params],
"response_params": [p.model_dump() for p in payload.response_params],
},
ensure_ascii=False,
)
@@ -3131,6 +3272,82 @@ def upsert_mcp_tool(payload: McpToolCreate, db: Session = Depends(get_db), user:
return model_to_mcp_out(row)
@app.get("/api/environments", response_model=list[EnvironmentConfigOut])
def list_environments(db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
rows = _scope_query_by_user(db.query(EnvironmentConfig), EnvironmentConfig, user).order_by(EnvironmentConfig.id.desc()).all()
return [EnvironmentConfigOut.model_validate(row) for row in rows]
@app.post("/api/environments", response_model=EnvironmentConfigOut)
def create_environment(payload: EnvironmentConfigCreate, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
row = EnvironmentConfig(
name=payload.name.strip(),
base_url=payload.base_url.strip(),
auth_token=payload.auth_token.strip(),
description=payload.description.strip(),
status="draft",
creator_id=user.id,
creator_name=user.display_name or user.username,
)
db.add(row)
db.commit()
db.refresh(row)
return EnvironmentConfigOut.model_validate(row)
@app.put("/api/environments/{env_id}", response_model=EnvironmentConfigOut)
def update_environment(env_id: int, payload: EnvironmentConfigUpdate, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
row = db.query(EnvironmentConfig).filter(EnvironmentConfig.id == env_id).first()
if not row:
raise HTTPException(status_code=404, detail="environment not found")
_assert_owner_or_superadmin(user, row)
row.name = payload.name.strip()
row.base_url = payload.base_url.strip()
row.auth_token = payload.auth_token.strip()
row.description = payload.description.strip()
db.commit()
db.refresh(row)
return EnvironmentConfigOut.model_validate(row)
@app.post("/api/environments/{env_id}/activate")
def activate_environment(env_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
row = db.query(EnvironmentConfig).filter(EnvironmentConfig.id == env_id).first()
if not row:
raise HTTPException(status_code=404, detail="environment not found")
_assert_owner_or_superadmin(user, row)
active_rows = _scope_query_by_user(
db.query(EnvironmentConfig).filter(EnvironmentConfig.status == "active"), EnvironmentConfig, user
).all()
for active_row in active_rows:
active_row.status = "archived"
row.status = "active"
db.commit()
db.refresh(row)
return EnvironmentConfigOut.model_validate(row)
@app.delete("/api/environments/{env_id}")
def delete_environment(env_id: int, db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
row = db.query(EnvironmentConfig).filter(EnvironmentConfig.id == env_id).first()
if not row:
raise HTTPException(status_code=404, detail="environment not found")
_assert_owner_or_superadmin(user, row)
db.delete(row)
db.commit()
return {"ok": True}
@app.get("/api/environments/active", response_model=EnvironmentConfigOut | None)
def get_active_environment(db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
row = _scope_query_by_user(
db.query(EnvironmentConfig).filter(EnvironmentConfig.status == "active"), EnvironmentConfig, user
).first()
if not row:
return None
return EnvironmentConfigOut.model_validate(row)
@app.get("/api/ssh-profiles", response_model=list[SshProfileOut])
def list_ssh_profiles(db: Session = Depends(get_db), user: User = Depends(_get_current_user)):
rows = _scope_query_by_user(db.query(SshProfile), SshProfile, user).order_by(SshProfile.id.desc()).all()
+15
View File
@@ -217,3 +217,18 @@ class WorkflowRun(Base):
summary_json = Column(Text, nullable=False, default="{}")
payload_json = Column(Text, nullable=False, default="{}")
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
class EnvironmentConfig(Base):
__tablename__ = "environment_configs"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(128), nullable=False, index=True)
base_url = Column(String(512), nullable=False, default="")
auth_token = Column(String(512), nullable=False, default="")
description = Column(Text, nullable=False, default="")
status = Column(String(16), nullable=False, default="draft") # active | draft | archived
creator_id = Column(Integer, nullable=False, default=1, index=True)
creator_name = Column(String(64), nullable=False, default="admin")
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+37
View File
@@ -3,16 +3,26 @@ from typing import Any, Literal
from pydantic import BaseModel, Field
class ApiParamField(BaseModel):
name: str = ""
type: str = "string"
required: bool = False
description: str = ""
class ApiBase(BaseModel):
name: str
folder_path: str = ""
method: str = "GET"
url: str
description: str = ""
headers: dict[str, Any] = Field(default_factory=dict)
body: dict[str, Any] = Field(default_factory=dict)
query: dict[str, Any] = Field(default_factory=dict)
path_params: dict[str, Any] = Field(default_factory=dict)
timeout_seconds: float = 10.0
request_params: list[ApiParamField] = Field(default_factory=list)
response_params: list[ApiParamField] = Field(default_factory=list)
class ApiCreate(ApiBase):
@@ -387,6 +397,33 @@ class FolderMoveRequest(BaseModel):
to_path: str
class EnvironmentConfigBase(BaseModel):
name: str
base_url: str = ""
auth_token: str = ""
description: str = ""
class EnvironmentConfigCreate(EnvironmentConfigBase):
pass
class EnvironmentConfigUpdate(EnvironmentConfigBase):
pass
class EnvironmentConfigOut(EnvironmentConfigBase):
id: int
status: str = "draft"
creator_id: int = 0
creator_name: str = ""
created_at: Any = None
updated_at: Any = None
class Config:
from_attributes = True
class McpInvokeRequest(BaseModel):
tool: str
arguments: dict[str, Any] = Field(default_factory=dict)