"""Nexus — Terminal Quick Commands API CRUD for terminal quick commands, persisted in MySQL. Built-in commands (is_builtin=True) are seeded on startup and cannot be deleted/edited. """ from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from server.api.dependencies import get_db from server.api.auth_jwt import get_current_admin from server.domain.models import QuickCommand, Admin, AuditLog from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl router = APIRouter(prefix="/api/terminal", tags=["terminal"]) # ── Schemas ── class QuickCommandCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100, description="命令名称") cmd: str = Field(..., min_length=1, max_length=500, description="命令内容") class QuickCommandUpdate(BaseModel): name: str | None = Field(None, min_length=1, max_length=100, description="命令名称") cmd: str | None = Field(None, min_length=1, max_length=500, description="命令内容") class QuickCommandReorder(BaseModel): """自定义命令 id 列表,按展示顺序(靠前 = 优先级更高)""" ids: list[int] = Field(..., min_length=1) # ── Built-in commands seed data ── # 内置命令 sort_order 从 1000 起,保证自定义命令(0…)始终排在前面 BUILTIN_SORT_BASE = 1000 BUILTIN_COMMANDS = [ {"name": "系统状态", "cmd": "systemctl status --no-pager 2>&1 | head -20\r", "sort_order": BUILTIN_SORT_BASE + 1}, {"name": "系统日志", "cmd": "journalctl -n 30 --no-pager 2>&1\r", "sort_order": BUILTIN_SORT_BASE + 2}, {"name": "磁盘使用", "cmd": "df -h\r", "sort_order": BUILTIN_SORT_BASE + 3}, {"name": "内存使用", "cmd": "free -h\r", "sort_order": BUILTIN_SORT_BASE + 4}, {"name": "进程列表", "cmd": "ps aux --sort=-%mem 2>&1 | head -15\r", "sort_order": BUILTIN_SORT_BASE + 5}, ] async def seed_builtin_commands(db: AsyncSession) -> None: """Ensure built-in commands exist in DB. Called on app startup.""" result = await db.execute( select(QuickCommand).where(QuickCommand.is_builtin.is_(True)) ) existing = {qc.name for qc in result.scalars().all()} for builtin in BUILTIN_COMMANDS: if builtin["name"] not in existing: qc = QuickCommand( name=builtin["name"], cmd=builtin["cmd"], is_builtin=True, sort_order=builtin["sort_order"], ) db.add(qc) if len(existing) < len(BUILTIN_COMMANDS): await db.commit() # ── Routes ── @router.get("/quick-commands", response_model=list) async def list_quick_commands( admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """List all quick commands: 自定义优先(sort_order 小在前),其次内置""" result = await db.execute( select(QuickCommand).order_by( QuickCommand.is_builtin.asc(), QuickCommand.sort_order.asc(), QuickCommand.id.asc(), ) ) commands = result.scalars().all() return [_qc_to_dict(qc) for qc in commands] @router.post("/quick-commands", response_model=dict, status_code=201) async def create_quick_command( payload: QuickCommandCreate, request: Request, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """Create a custom quick command(插入队首:sort_order 比现有自定义最小值再小 1)""" result = await db.execute( select(QuickCommand.sort_order) .where(QuickCommand.is_builtin.is_(False)) .order_by(QuickCommand.sort_order.asc()) .limit(1) ) min_order = result.scalar_one_or_none() next_order = (min_order - 1) if min_order is not None else 0 qc = QuickCommand( name=payload.name, cmd=payload.cmd, is_builtin=False, sort_order=next_order, created_by=admin.username, ) db.add(qc) await db.commit() await db.refresh(qc) audit_repo = AuditLogRepositoryImpl(db) await audit_repo.create(AuditLog( admin_username=admin.username, action="create_quick_command", target_type="quick_command", target_id=qc.id, detail=f"名称={qc.name}", ip_address=request.client.host if request.client else "", )) return _qc_to_dict(qc) @router.put("/quick-commands/{id}", response_model=dict) async def update_quick_command( id: int, payload: QuickCommandUpdate, request: Request, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """Update a custom quick command (built-in commands cannot be edited)""" result = await db.execute(select(QuickCommand).where(QuickCommand.id == id)) qc = result.scalar_one_or_none() if not qc: raise HTTPException(status_code=404, detail="快捷命令不存在") if qc.is_builtin: raise HTTPException(status_code=403, detail="系统内置命令不可编辑") if payload.name is not None: qc.name = payload.name if payload.cmd is not None: qc.cmd = payload.cmd await db.commit() await db.refresh(qc) audit_repo = AuditLogRepositoryImpl(db) await audit_repo.create(AuditLog( admin_username=admin.username, action="update_quick_command", target_type="quick_command", target_id=id, detail=f"名称={qc.name}", ip_address=request.client.host if request.client else "", )) return _qc_to_dict(qc) @router.put("/quick-commands/reorder", response_model=dict) async def reorder_quick_commands( payload: QuickCommandReorder, request: Request, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """重排自定义快捷命令顺序(ids[0] 优先级最高)""" for idx, cmd_id in enumerate(payload.ids): result = await db.execute(select(QuickCommand).where(QuickCommand.id == cmd_id)) qc = result.scalar_one_or_none() if not qc: raise HTTPException(status_code=404, detail=f"快捷命令 #{cmd_id} 不存在") if qc.is_builtin: raise HTTPException(status_code=403, detail="系统内置命令不可排序") qc.sort_order = idx await db.commit() audit_repo = AuditLogRepositoryImpl(db) await audit_repo.create(AuditLog( admin_username=admin.username, action="reorder_quick_commands", target_type="quick_command", target_id=0, detail=f"顺序={','.join(str(i) for i in payload.ids)}", ip_address=request.client.host if request.client else "", )) result = await db.execute( select(QuickCommand).order_by( QuickCommand.is_builtin.asc(), QuickCommand.sort_order.asc(), QuickCommand.id.asc(), ) ) return {"items": [_qc_to_dict(qc) for qc in result.scalars().all()]} @router.delete("/quick-commands/{id}", status_code=204) async def delete_quick_command( id: int, request: Request, admin: Admin = Depends(get_current_admin), db: AsyncSession = Depends(get_db), ): """Delete a custom quick command (built-in commands cannot be deleted)""" result = await db.execute(select(QuickCommand).where(QuickCommand.id == id)) qc = result.scalar_one_or_none() if not qc: raise HTTPException(status_code=404, detail="快捷命令不存在") if qc.is_builtin: raise HTTPException(status_code=403, detail="系统内置命令不可删除") await db.delete(qc) await db.commit() audit_repo = AuditLogRepositoryImpl(db) await audit_repo.create(AuditLog( admin_username=admin.username, action="delete_quick_command", target_type="quick_command", target_id=id, detail=f"名称={qc.name}", ip_address=request.client.host if request.client else "", )) # ── Helper ── def _qc_to_dict(qc: QuickCommand) -> dict: return { "id": qc.id, "name": qc.name, "cmd": qc.cmd, "is_builtin": qc.is_builtin, "sort_order": qc.sort_order, "created_by": qc.created_by, "created_at": str(qc.created_at) if qc.created_at else None, }