diff --git a/backend/app/api/v1/endpoints/instance.py b/backend/app/api/v1/endpoints/instance.py index 3b33766..fe5b159 100644 --- a/backend/app/api/v1/endpoints/instance.py +++ b/backend/app/api/v1/endpoints/instance.py @@ -53,6 +53,49 @@ async def _get_workflow_names(db: AsyncSession, instances: list) -> dict: return {w.id: w.name for w in result.scalars().all()} +@router.get("/accessible", response_model=List[InstanceResponse]) +async def list_accessible_instances( + access_type: str = "readonly", + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """返回当前用户有权访问的实例列表(按权限组的 access_type 过滤)。 + + 所有用户(包括 admin)统一按权限组关联过滤: + user_groups → instance_group_members(access_type) → 返回对应实例 + access_type: readonly=查询授权, write=变更授权 + """ + from app.models.instance_group import instance_group_members, user_groups as ug_table + + # 查用户关联的所有权限组 + grp_result = await db.execute( + select(ug_table.c.group_id).where(ug_table.c.user_id == current_user.id) + ) + group_ids = [row[0] for row in grp_result.all()] + if not group_ids: + return [] + + # 从 instance_group_members 中获取指定 access_type 的 instance_id + mem_result = await db.execute( + select(instance_group_members.c.instance_id) + .where( + instance_group_members.c.group_id.in_(group_ids), + instance_group_members.c.access_type == access_type, + ) + .distinct() + ) + instance_ids = [row[0] for row in mem_result.all()] + if not instance_ids: + return [] + + # 查实例详情 + stmt = select(Instance).where(Instance.id.in_(instance_ids)).order_by(Instance.id) + result = await db.execute(stmt) + instances = result.scalars().all() + wnames = await _get_workflow_names(db, instances) + return [_serialize_json_fields(i, wnames.get(i.workflow_id)) for i in instances] + + @router.get("", response_model=List[InstanceResponse]) async def list_instances( db_type: Optional[str] = None, diff --git a/backend/app/api/v1/endpoints/instance_group.py b/backend/app/api/v1/endpoints/instance_group.py index 13d52a4..7f4f011 100644 --- a/backend/app/api/v1/endpoints/instance_group.py +++ b/backend/app/api/v1/endpoints/instance_group.py @@ -117,7 +117,7 @@ async def _sync_members( ) -@router.get("", response_model=List[InstanceGroupResponse]) +@router.get("", response_model=List[InstanceGroupDetailResponse]) async def list_instance_groups( db_type: Optional[str] = None, db: AsyncSession = Depends(get_db), @@ -131,7 +131,7 @@ async def list_instance_groups( return [await _serialize_group_detail(g, db) for g in groups] -@router.post("", response_model=InstanceGroupResponse, status_code=201) +@router.post("", response_model=InstanceGroupDetailResponse, status_code=201) async def create_instance_group( data: InstanceGroupCreate, db: AsyncSession = Depends(get_db), @@ -164,7 +164,7 @@ async def create_instance_group( await _sync_members(db, group.id, data.readonly_instance_ids, data.write_instance_ids) await db.commit() - return await _serialize_group(group, db) + return await _serialize_group_detail(group, db) @router.get("/{group_id}", response_model=InstanceGroupDetailResponse) @@ -182,7 +182,7 @@ async def get_instance_group( return await _serialize_group_detail(group, db) -@router.put("/{group_id}", response_model=InstanceGroupResponse) +@router.put("/{group_id}", response_model=InstanceGroupDetailResponse) async def update_instance_group( group_id: int, data: InstanceGroupUpdate, @@ -242,7 +242,7 @@ async def update_instance_group( ) await db.commit() - return await _serialize_group(group, db) + return await _serialize_group_detail(group, db) @router.delete("/{group_id}") diff --git a/backend/app/api/v1/endpoints/scheduled_task.py b/backend/app/api/v1/endpoints/scheduled_task.py new file mode 100644 index 0000000..ff5116f --- /dev/null +++ b/backend/app/api/v1/endpoints/scheduled_task.py @@ -0,0 +1,287 @@ +"""定时任务 API — 基于 SQL 的自动化任务管理。""" +import time +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select, func as sa_func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import get_current_user, require_role +from app.models.user import User +from app.models.instance import Instance +from app.models.scheduled_task import ScheduledTask, ScheduledTaskLog +from app.schemas.scheduled_task import ( + ScheduledTaskCreate, ScheduledTaskUpdate, + ScheduledTaskResponse, ScheduledTaskLogResponse, TaskRunResponse, +) +from app.services.db_connector import DBConnector + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/scheduled-tasks", tags=["自动化任务"]) + + +def _serialize(task: ScheduledTask, instance_name: str = None, instance_db_type: str = None) -> dict: + return { + "id": task.id, + "name": task.name, + "description": task.description, + "task_sql_type": task.task_sql_type, + "instance_id": task.instance_id, + "instance_name": instance_name, + "instance_db_type": instance_db_type, + "target_db": task.target_db, + "target_table": task.target_table, + "sql_content": task.sql_content, + "cron_expression": task.cron_expression, + "is_enabled": task.is_enabled, + "last_run_at": task.last_run_at, + "last_run_status": task.last_run_status, + "last_run_message": task.last_run_message, + "next_run_at": task.next_run_at, + "run_count": task.run_count or 0, + "created_by": task.created_by, + "created_at": task.created_at, + } + + +async def _get_instance(db: AsyncSession, instance_id: int) -> Instance: + result = await db.execute(select(Instance).where(Instance.id == instance_id)) + inst = result.scalar_one_or_none() + if not inst: + raise HTTPException(404, "数据源不存在") + return inst + + +# ── CRUD ────────────────────────────────────────────────────── + +@router.get("", response_model=list[ScheduledTaskResponse]) +async def list_tasks( + task_sql_type: Optional[str] = None, + is_enabled: Optional[bool] = None, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_role("admin")), +): + """列出所有定时任务。""" + q = select(ScheduledTask).order_by(ScheduledTask.created_at.desc()) + if task_sql_type: + q = q.where(ScheduledTask.task_sql_type == task_sql_type) + if is_enabled is not None: + q = q.where(ScheduledTask.is_enabled == is_enabled) + result = await db.execute(q) + tasks = result.scalars().all() + + # batch load instance info + inst_ids = {t.instance_id for t in tasks} + inst_map = {} + if inst_ids: + ir = await db.execute(select(Instance).where(Instance.id.in_(inst_ids))) + for inst in ir.scalars().all(): + inst_map[inst.id] = inst + + return [_serialize(t, inst_map.get(t.instance_id).name if inst_map.get(t.instance_id) else None, + inst_map.get(t.instance_id).db_type if inst_map.get(t.instance_id) else None) + for t in tasks] + + +@router.post("", response_model=ScheduledTaskResponse, status_code=201) +async def create_task( + body: ScheduledTaskCreate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_role("admin")), +): + """创建定时任务。""" + # validate instance exists + inst = await _get_instance(db, body.instance_id) + + task = ScheduledTask( + name=body.name, + description=body.description, + task_sql_type=body.task_sql_type.value, + instance_id=body.instance_id, + target_db=body.target_db, + target_table=body.target_table, + sql_content=body.sql_content, + cron_expression=body.cron_expression, + is_enabled=body.is_enabled, + created_by=user.id, + ) + db.add(task) + await db.flush() + await db.refresh(task) + return _serialize(task, inst.name, inst.db_type) + + +@router.get("/{task_id}", response_model=ScheduledTaskResponse) +async def get_task( + task_id: int, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_role("admin")), +): + """获取任务详情。""" + result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id)) + task = result.scalar_one_or_none() + if not task: + raise HTTPException(404, "任务不存在") + inst = await _get_instance(db, task.instance_id) + return _serialize(task, inst.name, inst.db_type) + + +@router.put("/{task_id}", response_model=ScheduledTaskResponse) +async def update_task( + task_id: int, + body: ScheduledTaskUpdate, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_role("admin")), +): + """更新任务。""" + result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id)) + task = result.scalar_one_or_none() + if not task: + raise HTTPException(404, "任务不存在") + + update_data = body.model_dump(exclude_unset=True) + if "task_sql_type" in update_data and update_data["task_sql_type"] is not None: + update_data["task_sql_type"] = update_data["task_sql_type"].value + for k, v in update_data.items(): + setattr(task, k, v) + + await db.flush() + await db.refresh(task) + + inst = await _get_instance(db, task.instance_id) + return _serialize(task, inst.name, inst.db_type) + + +@router.delete("/{task_id}") +async def delete_task( + task_id: int, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_role("admin")), +): + """删除任务。""" + result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id)) + task = result.scalar_one_or_none() + if not task: + raise HTTPException(404, "任务不存在") + await db.delete(task) + return {"detail": "已删除"} + + +@router.patch("/{task_id}/toggle") +async def toggle_task( + task_id: int, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_role("admin")), +): + """启用/禁用任务。""" + result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id)) + task = result.scalar_one_or_none() + if not task: + raise HTTPException(404, "任务不存在") + task.is_enabled = not task.is_enabled + await db.flush() + return {"is_enabled": task.is_enabled} + + +# ── 执行 ────────────────────────────────────────────────────── + +@router.post("/{task_id}/run", response_model=TaskRunResponse) +async def run_task( + task_id: int, + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_role("admin")), +): + """手动执行一次任务。""" + result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id)) + task = result.scalar_one_or_none() + if not task: + raise HTTPException(404, "任务不存在") + + inst = await _get_instance(db, task.instance_id) + target_db = task.target_db or inst.database or "" + + log = ScheduledTaskLog( + task_id=task.id, + status="running", + sql_content=task.sql_content, + ) + db.add(log) + await db.flush() + + start = time.time() + try: + msg = await DBConnector.execute_dml(inst, target_db, task.sql_content) + elapsed = int((time.time() - start) * 1000) + # parse affected rows from message like "执行成功,影响 3 行" + affected = 0 + import re + m = re.search(r'(\d+)', msg) + if m: + affected = int(m.group(1)) + + log.status = "success" + log.affected_rows = affected + log.duration_ms = elapsed + log.finished_at = __import__("datetime").datetime.now() + + task.last_run_at = __import__("datetime").datetime.now() + task.last_run_status = "success" + task.last_run_message = msg + task.run_count = (task.run_count or 0) + 1 + await db.flush() + + return TaskRunResponse(success=True, message=msg, affected_rows=affected, duration_ms=elapsed) + + except Exception as e: + elapsed = int((time.time() - start) * 1000) + error_msg = str(e) + + log.status = "failed" + log.error_message = error_msg + log.duration_ms = elapsed + log.finished_at = __import__("datetime").datetime.now() + + task.last_run_at = __import__("datetime").datetime.now() + task.last_run_status = "failed" + task.last_run_message = error_msg + task.run_count = (task.run_count or 0) + 1 + await db.flush() + + return TaskRunResponse(success=False, message=error_msg, duration_ms=elapsed) + + +# ── 执行日志 ────────────────────────────────────────────────── + +@router.get("/{task_id}/logs", response_model=list[ScheduledTaskLogResponse]) +async def get_task_logs( + task_id: int, + limit: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + _user: User = Depends(require_role("admin")), +): + """获取任务执行日志。""" + q = ( + select(ScheduledTaskLog) + .where(ScheduledTaskLog.task_id == task_id) + .order_by(ScheduledTaskLog.started_at.desc()) + .limit(limit) + ) + result = await db.execute(q) + logs = result.scalars().all() + return [ + { + "id": l.id, + "task_id": l.task_id, + "status": l.status, + "sql_content": l.sql_content, + "affected_rows": l.affected_rows or 0, + "duration_ms": l.duration_ms, + "error_message": l.error_message, + "started_at": l.started_at, + "finished_at": l.finished_at, + } + for l in logs + ] diff --git a/backend/app/api/v1/endpoints/ticket.py b/backend/app/api/v1/endpoints/ticket.py index 74de4ca..90b7f43 100644 --- a/backend/app/api/v1/endpoints/ticket.py +++ b/backend/app/api/v1/endpoints/ticket.py @@ -38,6 +38,24 @@ import logging logger = logging.getLogger(__name__) + +def _generate_ticket_no(ticket_id: int) -> str: + """生成工单编号,格式: YYYYMMDDHHmmss + 微秒前3位 + 序号补齐。 + + 示例: 20260522160543841 + + Args: + ticket_id: 工单自增ID,用作末尾序号 + + Returns: + 工单编号字符串 + """ + now = datetime.now() + base = now.strftime("%Y%m%d%H%M%S") + ms = f"{now.microsecond:06d}"[:3] # 取前3位毫秒 + seq = str(ticket_id).zfill(3)[-3:] # ID 补齐到3位 + return f"{base}{ms}{seq}" + if TYPE_CHECKING: from app.services.ticket_executor import ExecutionReport @@ -112,17 +130,10 @@ class TicketExecuteConfirm(BaseModel): async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]: - """丰富工单数据,添加申请人/审批人名称等信息。 - - Args: - ticket: 工单对象 - db: 数据库会话 - - Returns: - 丰富的工单字典 - """ + """丰富工单数据,添加申请人/审批人/实例名称/当前审核人等信息。""" data = { "id": ticket.id, + "ticket_no": ticket.ticket_no, "title": ticket.title, "db_type": ticket.db_type, "instance_id": ticket.instance_id, @@ -147,12 +158,39 @@ async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]: applicant = r.scalar_one_or_none() data["applicant_name"] = applicant.display_name if applicant else "未知" - # Resolve approver name + # Resolve approver name (最终审批人) if ticket.approver_id: r = await db.execute(select(User).where(User.id == ticket.approver_id)) approver = r.scalar_one_or_none() data["approver_name"] = approver.display_name if approver else "未知" + # Resolve instance name + if ticket.instance_id: + r = await db.execute(select(Instance).where(Instance.id == ticket.instance_id)) + inst = r.scalar_one_or_none() + data["instance_name"] = inst.name if inst else "未知" + + # Resolve current approver (审批/执行阶段的当前待审核人) + data["current_approver_name"] = None + if ticket.status in ("reviewing", "approved", "executing"): + pending_approval = await db.execute( + select(TicketApproval) + .where( + TicketApproval.ticket_id == ticket.id, + TicketApproval.action == "pending", + ) + .order_by(TicketApproval.step_number) + .limit(1) + ) + pending = pending_approval.scalar_one_or_none() + if pending: + if pending.approver_id: + r = await db.execute(select(User).where(User.id == pending.approver_id)) + approver_user = r.scalar_one_or_none() + data["current_approver_name"] = approver_user.display_name if approver_user else None + elif pending.approver_role: + data["current_approver_name"] = pending.approver_role + return data @@ -370,6 +408,10 @@ async def create_ticket( db.add(ticket) await db.flush() # flush to get ticket.id + # 生成工单编号 + ticket.ticket_no = _generate_ticket_no(ticket.id) + await db.flush() + # 7. 创建审批步骤(多级审批流) if default_workflow: wf_steps_result = await db.execute( diff --git a/backend/app/api/v1/endpoints/workflow.py b/backend/app/api/v1/endpoints/workflow.py index 11c36a2..4967f0b 100644 --- a/backend/app/api/v1/endpoints/workflow.py +++ b/backend/app/api/v1/endpoints/workflow.py @@ -95,11 +95,13 @@ async def create_workflow( ) db.add(step) - await db.commit() - # Reload with steps + await db.flush() + wf_id = workflow.id + # Reload with steps — use fresh query by id to avoid identity map issues + db.expire(workflow) result = await db.execute( select(WorkflowDefinition) - .where(WorkflowDefinition.id == workflow.id) + .where(WorkflowDefinition.id == wf_id) .options(selectinload(WorkflowDefinition.steps)) ) workflow = result.scalar_one() @@ -146,8 +148,35 @@ async def update_workflow( if data.is_active is not None: workflow.is_active = data.is_active - await db.commit() - await db.refresh(workflow) + # 更新步骤(先删后建) + if data.steps is not None: + from sqlalchemy import delete as sa_delete + await db.execute( + sa_delete(WorkflowStep).where(WorkflowStep.workflow_id == workflow_id) + ) + await db.flush() + for step_data in data.steps: + step = WorkflowStep( + workflow_id=workflow.id, + step_number=step_data.step_number, + name=step_data.name, + step_type=step_data.step_type, + approver_role=step_data.approver_role, + approver_ids=step_data.approver_ids, + is_required=step_data.is_required, + timeout_hours=step_data.timeout_hours, + auto_approve_on_timeout=step_data.auto_approve_on_timeout, + ) + db.add(step) + await db.flush() + # Reload with steps + db.expire(workflow) + result = await db.execute( + select(WorkflowDefinition) + .where(WorkflowDefinition.id == workflow_id) + .options(selectinload(WorkflowDefinition.steps)) + ) + workflow = result.scalar_one() return _serialize_workflow(workflow) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 2ee22f1..1c06f70 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -7,7 +7,7 @@ from app.api.v1.endpoints import ( instance_group, masking_rule, rollback, workflow, impact, schema_drift, pool, mongo, query_history, sql_assistant, db_types, - settings, + settings, scheduled_task, ) api_router = APIRouter(prefix="/api/v1") @@ -40,3 +40,4 @@ api_router.include_router(sql_assistant.router) api_router.include_router(db_types.router) api_router.include_router(settings.router, prefix="/settings", tags=["系统设置"]) +api_router.include_router(scheduled_task.router) diff --git a/backend/app/models/scheduled_task.py b/backend/app/models/scheduled_task.py index 31361ae..c4fb661 100644 --- a/backend/app/models/scheduled_task.py +++ b/backend/app/models/scheduled_task.py @@ -1,22 +1,44 @@ -"""定时任务模型。""" +"""定时任务模型 — 基于纯SQL的自动化任务。""" from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, func from app.core.database import Base class ScheduledTask(Base): - """定时任务。""" + """定时任务:insert / update / delete 三种类型,指定数据源+库表+纯SQL。""" __tablename__ = "scheduled_tasks" id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String(100), nullable=False) - task_type = Column(String(30), nullable=False) - instance_id = Column(Integer, ForeignKey("instances.id"), nullable=True) + description = Column(Text, nullable=True) + task_sql_type = Column(String(20), nullable=False, default="insert") # insert / update / delete + instance_id = Column(Integer, ForeignKey("instances.id", ondelete="CASCADE"), nullable=False) + target_db = Column(String(100), nullable=True) + target_table = Column(String(100), nullable=True) + sql_content = Column(Text, nullable=False) cron_expression = Column(String(50), nullable=False) - config = Column(Text, nullable=True) # JSON is_enabled = Column(Boolean, default=True) + # 运行状态 last_run_at = Column(DateTime, nullable=True) - last_run_status = Column(String(20), nullable=True) + last_run_status = Column(String(20), nullable=True) # success / failed last_run_message = Column(Text, nullable=True) - created_by = Column(Integer, ForeignKey("users.id"), nullable=True) + next_run_at = Column(DateTime, nullable=True) + run_count = Column(Integer, default=0) + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + + +class ScheduledTaskLog(Base): + """定时任务执行日志。""" + __tablename__ = "scheduled_task_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + task_id = Column(Integer, ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False) + status = Column(String(20), nullable=False) # success / failed / running + sql_content = Column(Text, nullable=True) + affected_rows = Column(Integer, default=0) + duration_ms = Column(Integer, nullable=True) + error_message = Column(Text, nullable=True) + started_at = Column(DateTime, server_default=func.now()) + finished_at = Column(DateTime, nullable=True) diff --git a/backend/app/models/ticket.py b/backend/app/models/ticket.py index 25f575f..a3ef296 100644 --- a/backend/app/models/ticket.py +++ b/backend/app/models/ticket.py @@ -7,6 +7,7 @@ class Ticket(Base): __tablename__ = "tickets" id = Column(Integer, primary_key=True, autoincrement=True) + ticket_no = Column(String(20), unique=True, nullable=True, comment="工单编号,格式: 20260522160543841") title = Column(String(200), nullable=False) db_type = Column(String(20), nullable=False) instance_id = Column(Integer, ForeignKey("instances.id"), nullable=False) diff --git a/backend/app/schemas/scheduled_task.py b/backend/app/schemas/scheduled_task.py new file mode 100644 index 0000000..b26b275 --- /dev/null +++ b/backend/app/schemas/scheduled_task.py @@ -0,0 +1,82 @@ +"""定时任务 Schema — 基于 SQL 的自动化任务。""" +from typing import Optional, List +from pydantic import BaseModel, Field +from datetime import datetime +from enum import Enum + + +class TaskSqlType(str, Enum): + insert = "insert" + update = "update" + delete = "delete" + + +class ScheduledTaskCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = None + task_sql_type: TaskSqlType + instance_id: int + target_db: Optional[str] = None + target_table: Optional[str] = None + sql_content: str = Field(..., min_length=1) + cron_expression: str = Field(..., min_length=1) + is_enabled: bool = True + + +class ScheduledTaskUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + description: Optional[str] = None + task_sql_type: Optional[TaskSqlType] = None + instance_id: Optional[int] = None + target_db: Optional[str] = None + target_table: Optional[str] = None + sql_content: Optional[str] = Field(None, min_length=1) + cron_expression: Optional[str] = Field(None, min_length=1) + is_enabled: Optional[bool] = None + + +class ScheduledTaskResponse(BaseModel): + id: int + name: str + description: Optional[str] + task_sql_type: str + instance_id: int + instance_name: Optional[str] = None + instance_db_type: Optional[str] = None + target_db: Optional[str] + target_table: Optional[str] + sql_content: str + cron_expression: str + is_enabled: bool + last_run_at: Optional[datetime] + last_run_status: Optional[str] + last_run_message: Optional[str] + next_run_at: Optional[datetime] + run_count: int + created_by: Optional[int] + created_at: datetime + + class Config: + from_attributes = True + + +class ScheduledTaskLogResponse(BaseModel): + id: int + task_id: int + status: str + sql_content: Optional[str] + affected_rows: int + duration_ms: Optional[int] + error_message: Optional[str] + started_at: datetime + finished_at: Optional[datetime] + + class Config: + from_attributes = True + + +class TaskRunResponse(BaseModel): + success: bool + message: str + affected_rows: int = 0 + duration_ms: int = 0 diff --git a/backend/app/schemas/ticket.py b/backend/app/schemas/ticket.py index 0570dab..a435f84 100644 --- a/backend/app/schemas/ticket.py +++ b/backend/app/schemas/ticket.py @@ -21,9 +21,11 @@ class TicketReview(BaseModel): class TicketResponse(BaseModel): id: int + ticket_no: Optional[str] = None title: str db_type: str instance_id: int + instance_name: Optional[str] = None target_database: str sql_content: str rollback_sql: Optional[str] = None @@ -33,6 +35,7 @@ class TicketResponse(BaseModel): applicant_name: Optional[str] = None approver_id: Optional[int] = None approver_name: Optional[str] = None + current_approver_name: Optional[str] = None reason: Optional[str] = None audit_result: Optional[str] = None execute_result: Optional[str] = None diff --git a/backend/app/schemas/workflow.py b/backend/app/schemas/workflow.py index dd8ea6e..eec7f9e 100644 --- a/backend/app/schemas/workflow.py +++ b/backend/app/schemas/workflow.py @@ -33,6 +33,7 @@ class WorkflowUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None is_active: Optional[bool] = None + steps: Optional[List[WorkflowStepCreate]] = None class WorkflowResponse(BaseModel): diff --git a/frontend/src/api/instance.ts b/frontend/src/api/instance.ts index 0a2538d..4a7aeb6 100644 --- a/frontend/src/api/instance.ts +++ b/frontend/src/api/instance.ts @@ -2,6 +2,7 @@ import request from './request'; export const instanceApi = { list: (params?: any) => request.get('/instances', { params }), + accessible: (accessType: 'readonly' | 'write') => request.get('/instances/accessible', { params: { access_type: accessType } }), get: (id: number) => request.get(`/instances/${id}`), create: (data: any) => request.post('/instances', data), update: (id: number, data: any) => request.put(`/instances/${id}`, data), diff --git a/frontend/src/components/EditInstanceDialog.tsx b/frontend/src/components/EditInstanceDialog.tsx index 213d99c..e50e11f 100644 --- a/frontend/src/components/EditInstanceDialog.tsx +++ b/frontend/src/components/EditInstanceDialog.tsx @@ -34,12 +34,13 @@ interface Props { open: boolean; onClose: () => void; onSaved: () => void; + mode?: 'edit' | 'clone'; } /* ------------------------------------------------------------------ */ /* Reusable Edit Instance Dialog */ /* ------------------------------------------------------------------ */ -export default function EditInstanceDialog({ instance, open, onClose, onSaved }: Props) { +export default function EditInstanceDialog({ instance, open, onClose, onSaved, mode = 'edit' }: Props) { const [name, setName] = useState(instance.name); const [host, setHost] = useState(instance.host); const [port, setPort] = useState(String(instance.port)); @@ -105,19 +106,31 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }: ? maskFields.filter(m => m.table && m.field) : undefined; - await instanceApi.update(instance.id, { + const payload: Record = { name: name.trim(), host: host.trim(), port: Number(port), username: username.trim() || undefined, - password: password || undefined, database: database.trim() || undefined, env, access_mode: accessMode, workflow_id: workflowId, excluded_databases: parsedExcluded, mask_fields: parsedMask, - }); + }; + if (mode === 'clone') { + payload.db_type = instance.db_type; + if (!password) { + alert('克隆数据源需要重新输入密码'); + setSubmitting(false); + return; + } + payload.password = password; + await instanceApi.create(payload); + } else { + payload.password = password || undefined; + await instanceApi.update(instance.id, payload); + } onSaved(); } catch (e: unknown) { const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'; @@ -140,7 +153,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }: > {/* Header */}
-

编辑数据源

+

{mode === 'clone' ? '克隆数据源' : '编辑数据源'}

@@ -172,7 +185,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }: setUsername(e.target.value)} />
- + setPassword(e.target.value)} placeholder="••••••" />
@@ -254,7 +267,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved }: diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index fad1d5a..f0d902b 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -164,7 +164,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { dataSourcePermissions: [], customPermissions: {}, }); - const [isLoggedIn, setIsLoggedIn] = useState(() => !!localStorage.getItem('token')); + + // 从 token 恢复基本用户信息(页面刷新时) + const [isLoggedIn, setIsLoggedIn] = useState(() => { + const token = localStorage.getItem('token'); + if (!token) return false; + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const role = payload.role as UserRole; + if (role) { + setUser(u => ({ + ...u, + id: Number(payload.sub) || 0, + role, + roleName: roleNameMap[role] || role, + avatar: role.charAt(0).toUpperCase(), + })); + } + } catch { /* ignore */ } + return true; + }); /** 权限缓存引用 */ const permissionCacheRef = useRef({ diff --git a/frontend/src/pages/InstanceGroupPage.tsx b/frontend/src/pages/InstanceGroupPage.tsx index e1f37a3..552da1f 100644 --- a/frontend/src/pages/InstanceGroupPage.tsx +++ b/frontend/src/pages/InstanceGroupPage.tsx @@ -21,7 +21,7 @@ const DB_TYPE_COLORS: Record = { /* ───────── Transfer Component ───────── */ -interface TransferItem { label: string; value: string; tag?: string; tagColor?: string } +interface TransferItem { label: string; value: string; tag?: string; tagColor?: string; accessMode?: string } interface TransferProps { title: string; @@ -151,10 +151,23 @@ export default function InstanceGroupPage() { value: String(i.id), tag: i.db_type, tagColor: DB_TYPE_COLORS[i.db_type] || '', + accessMode: i.access_mode || 'readwrite', })), [allInstances] ); + // 查询授权:只匹配 read + readwrite 实例 + const readonlyTransferItems = useMemo(() => + transferItems.filter(i => i.accessMode === 'read' || i.accessMode === 'readwrite'), + [transferItems] + ); + + // 变更授权:只匹配 write + readwrite 实例 + const writeTransferItems = useMemo(() => + transferItems.filter(i => i.accessMode === 'write' || i.accessMode === 'readwrite'), + [transferItems] + ); + const filteredGroups = useMemo(() => groups.filter(g => !search || g.name.includes(search)), [groups, search]); const filteredUsers = useMemo(() => allUsers.filter(u => !userSearch || (u.display_name || u.username).toLowerCase().includes(userSearch.toLowerCase())), [allUsers, userSearch]); @@ -263,20 +276,20 @@ export default function InstanceGroupPage() {
- 只读查看 + 查询授权 — 查询数据的实例
- +
{/* Write Transfer */}
- 写入变更 + 变更授权 — 数据变更工单的实例
- +
@@ -297,7 +310,7 @@ export default function InstanceGroupPage() {

数据库权限组

-

每个策略包含「只读查看」和「写入变更」两类实例

+

每个策略包含「查询授权」和「变更授权」两类实例

{canManage && ( + )} + {canEdit && ( + + )} +
); @@ -627,12 +674,23 @@ export default function InstancePage() { - +
+ + {canAdd && ( + + )} +
); @@ -643,6 +701,17 @@ export default function InstancePage() { )} + + {/* Clone Dialog */} + {cloneSource && showClone && ( + { setShowClone(false); setCloneSource(null); }} + onSaved={() => { setShowClone(false); setCloneSource(null); fetchInstances(); }} + mode="clone" + /> + )} ); } @@ -650,10 +719,10 @@ export default function InstancePage() { /* ------------------------------------------------------------------ */ /* InstanceDetail */ /* ------------------------------------------------------------------ */ -function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete, onEdit }: { +function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete, onEdit, onClone }: { instance: InstanceItem; onBack: () => void; config: { icon: React.ElementType; color: string; bg: string }; - canEdit: boolean; canDelete: boolean; onDelete: () => void; onEdit: () => void; + canEdit: boolean; canDelete: boolean; onDelete: () => void; onEdit: () => void; onClone?: () => void; }) { const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null); @@ -710,6 +779,12 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete {testing ? '测试中...' : '测试连接'} + {onClone && ( + + )} {canEdit && ( )) )} - {/* Filter Bar */} -
-
- + {/* Search + Filters */} +
+
+ { setSearch(e.target.value); setPage(1); }} /> -
- - -
- - {/* Expanded Filters */} - {showFilters && ( -
-
- - - - -
- - setApplicantFilter(e.target.value)} - /> -
-
- {hasActiveFilters && ( -
- 已选条件: - {statusFilter && ( - - 状态: {statusMap[statusFilter]} - - - )} - {dbTypeFilter && ( - - DB: {dbTypeFilter.toUpperCase()} - - - )} - - {dateRange !== 'all' && ( - - - {dateRange === 'today' ? '今天' : dateRange === 'week' ? '近7天' : '近30天'} - - - )} - -
+ {search && ( + )}
- )} - - {/* Batch Operations */} - {selectedIds.size > 0 && ( -
-
- 已选择 {selectedIds.size} 个工单 - -
-
- {canApprove && ( - - )} -
-
- )} +
{/* Ticket Table */} @@ -476,14 +274,17 @@ export default function TicketsPage() { - - - - - - - - + + + + + + + @@ -493,333 +294,239 @@ export default function TicketsPage() { ) : tickets.length === 0 ? ( - ) : clientFilteredTickets.length === 0 ? ( -
- - 没有符合筛选条件的工单 -
) : ( - <> -
-
工单号标题数据库申请人状态创建时间操作工单编号实例名称提交人当前审核人状态 + + 操作
- - - - - - - - - - - - - - - {clientFilteredTickets.map(ticket => { - const isSelected = selectedIds.has(ticket.id); - return ( - - - - - - - - - - - - ); - })} - -
- - 工单号标题数据库申请人状态 - - 操作
- - TK-{ticket.id}{ticket.title} - - {ticket.db_type.toUpperCase()} - - -
- - {ticket.applicant_name || '-'} -
-
- - - {statusMap[ticket.status] || ticket.status} - - {new Date(ticket.create_time).toLocaleString()} -
- - {(ticket.status === 'pending' || ticket.status === 'reviewing') && ( - - )} -
-
-
- {/* Pagination */} -
- - 共 {total} 条记录 - {selectedIds.size > 0 && · 已选 {selectedIds.size} 条} - -
- - {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => { - const p = i + 1; - return ( - + {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => { + let p: number; + if (totalPages <= 5) { + p = i + 1; + } else if (page <= 3) { + p = i + 1; + } else if (page >= totalPages - 2) { + p = totalPages - 4 + i; + } else { + p = page - 2 + i; + } + return ( + + ); + })} +
- +
)} + + {/* Detail Dialog */} + {detailTicket && ( + setDetailTicket(null)} + canApprove={canApprove} + canExecute={canExecute} + onActionDone={() => { setDetailTicket(null); fetchTickets(); fetchStats(); }} + /> + )} ); } -/* ------------------------------------------------------------------ */ -/* NewTicketForm */ -/* ------------------------------------------------------------------ */ -function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreated: () => void }) { - const [title, setTitle] = useState(''); - const [dbType, setDbType] = useState('mysql'); - const [instanceId, setInstanceId] = useState(''); - const [targetDatabase, setTargetDatabase] = useState(''); - const [sqlContent, setSqlContent] = useState(''); - const [reason, setReason] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [instances, setInstances] = useState>([]); - const [error, setError] = useState(''); - - useEffect(() => { - instanceApi.list().then(res => { - const data = (res as { items?: unknown[] })?.items || res; - setInstances(Array.isArray(data) ? data : []); - }).catch(() => { setInstances([]); }); - }, []); - - const handleSubmit = useCallback(async () => { - if (!title || !sqlContent || !reason || !instanceId || !targetDatabase) { - setError('请填写所有必填项'); - return; - } - setSubmitting(true); - setError(''); - try { - await ticketApi.create({ - title, - db_type: dbType, - instance_id: Number(instanceId), - target_database: targetDatabase, - sql_content: sqlContent, - reason, - }); - onCreated(); - } catch (e: unknown) { - const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '提交失败'; - setError(msg); - } finally { - setSubmitting(false); - } - }, [title, dbType, instanceId, targetDatabase, sqlContent, reason, onCreated]); - - - - - - return ( -
-
-

新建数据变更工单

- -
- -
- {/* Title */} -
- - setTitle(e.target.value)} - className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20 transition-colors" - placeholder="请输入工单标题,如:更新用户状态字段" - /> -
- - {/* DB Type + Instance + Database */} -
-
- - -
-
- - -
-
- - setTargetDatabase(e.target.value)} - className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors" - placeholder="数据库名" - /> -
-
- - - - {/* SQL Editor */} -
- -
-