Files
opsxsql/backend/app/api/v1/endpoints/instance_group.py
T
logan 08f0f88cf1 feat: 工单列表重设计+权限组实例过滤+通知修复
后端:
- Ticket model 新增 ticket_no 字段(格式: YYYYMMDDHHmmss+ms+seq, 如20260522160543841)
- create_ticket 自动生成工单编号(flush后生成)
- _enrich_ticket 新增 instance_name, current_approver_name 字段
- current_approver: 从 ticket_approvals 查询第一个 pending 状态的审批步骤获取审核人
- TicketResponse schema 新增 ticket_no, instance_name, current_approver_name
- 新增 /instances/accessible endpoint(按用户权限组过滤实例, 无admin bypass)
- instance_group response_model 改为 InstanceGroupDetailResponse(含readonly/write实例)
- instance_group_members 脏数据清理, 重建 group_id=2 正确数据
- notification 模板修复: \n→\n 换行, 钉钉 \n→\n\n 双换行
- test_notification endpoint 改为真正调用 NotificationSender.send()
- auditor 角色添加到 audit_rule/ticket 只读端点
- seed_data() 创建4个默认用户(admin/zhangsan/laoliu/lurenjia)
- SQLite ALTER TABLE 添加 ticket_no 列, 迁移现有6条工单数据

前端:
- TicketsPage 完全重设计: 工单编号/实例名称/提交人/当前审核人/状态/操作
- 状态映射简化: pending/reviewing/approved→审核中, success→完成, failed→失败
- 执行详情弹框: 解析 execute_result JSON 展示状态/影响行数/耗时/验证/原始输出/错误信息
- 状态流程可视化(提交→审批→通过→执行→完成)
- 快速筛选: 全部/待审批/执行中/完成/失败 (合并 pending+reviewing+approved 统计)
- TicketCreatePage: 去掉数据库类型选择器, 用 accessible(write) 过滤变更实例
- QueriesPage: 用 accessible(readonly) 过滤查询实例
- instance.ts 新增 accessible 方法
- 分页优化: 动态计算页码范围(总页>5时中间滚动)
2026-05-24 22:55:51 +08:00

438 lines
14 KiB
Python

import json
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, delete as sa_delete, 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.instance_group import InstanceGroup, user_groups, instance_group_members
from app.schemas.instance_group import (
InstanceGroupCreate, InstanceGroupUpdate,
InstanceGroupResponse, InstanceGroupDetailResponse,
InstanceAssignRequest, InstanceRemoveRequest,
UserAssignRequest, UserGroupUpdateRequest,
)
router = APIRouter(prefix="/instance-groups", tags=["权限组管理"])
INSTANCE_FIELDS = ["id", "name", "db_type", "host", "port", "env", "status"]
def _serialize_instance(inst: Instance) -> dict:
return {k: getattr(inst, k, None) for k in INSTANCE_FIELDS}
async def _count_members(db: AsyncSession, group_id: int, access_type: str) -> int:
result = await db.execute(
select(sa_func.count()).select_from(instance_group_members).where(
instance_group_members.c.group_id == group_id,
instance_group_members.c.access_type == access_type,
)
)
return result.scalar() or 0
async def _get_member_instances(db: AsyncSession, group_id: int, access_type: str) -> List[dict]:
"""查询权限组中指定 access_type 的实例列表。"""
# Join instance_group_members with instances
result = await db.execute(
select(Instance, instance_group_members.c.access_type)
.join(instance_group_members, Instance.id == instance_group_members.c.instance_id)
.where(
instance_group_members.c.group_id == group_id,
instance_group_members.c.access_type == access_type,
)
)
rows = result.all()
return [_serialize_instance(row[0]) for row in rows]
async def _serialize_group(group: InstanceGroup, db: AsyncSession) -> dict:
ro_count = await _count_members(db, group.id, "readonly")
wr_count = await _count_members(db, group.id, "write")
user_count = len(group.users) if group.users else 0
return {
"id": group.id,
"name": group.name,
"description": group.description,
"db_type": group.db_type,
"permissions": group.get_permissions(),
"allowed_databases": group.get_allowed_databases(),
"is_active": group.is_active,
"created_at": group.created_at,
"updated_at": group.updated_at,
"readonly_instance_count": ro_count,
"write_instance_count": wr_count,
"user_count": user_count,
}
async def _serialize_group_detail(group: InstanceGroup, db: AsyncSession) -> dict:
d = await _serialize_group(group, db)
d["readonly_instances"] = await _get_member_instances(db, group.id, "readonly")
d["write_instances"] = await _get_member_instances(db, group.id, "write")
d["users"] = [
{
"id": u.id,
"username": u.username,
"display_name": u.display_name,
"role": u.role,
}
for u in (group.users or [])
]
return d
async def _sync_members(
db: AsyncSession,
group_id: int,
readonly_instance_ids: List[int],
write_instance_ids: List[int],
):
"""Clear and rewrite all instance_group_members for a group."""
await db.execute(
sa_delete(instance_group_members).where(
instance_group_members.c.group_id == group_id
)
)
for inst_id in readonly_instance_ids:
await db.execute(
instance_group_members.insert().values(
group_id=group_id,
instance_id=inst_id,
access_type="readonly",
)
)
for inst_id in write_instance_ids:
await db.execute(
instance_group_members.insert().values(
group_id=group_id,
instance_id=inst_id,
access_type="write",
)
)
@router.get("", response_model=List[InstanceGroupDetailResponse])
async def list_instance_groups(
db_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
stmt = select(InstanceGroup).order_by(InstanceGroup.id)
if db_type:
stmt = stmt.where(InstanceGroup.db_type == db_type)
result = await db.execute(stmt)
groups = result.scalars().all()
return [await _serialize_group_detail(g, db) for g in groups]
@router.post("", response_model=InstanceGroupDetailResponse, status_code=201)
async def create_instance_group(
data: InstanceGroupCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
# Check name uniqueness
existing = await db.execute(
select(InstanceGroup).where(InstanceGroup.name == data.name)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="权限组名称已存在")
group = InstanceGroup(
name=data.name,
description=data.description,
db_type=data.db_type,
allowed_databases=(
json.dumps(data.allowed_databases, ensure_ascii=False)
if data.allowed_databases
else None
),
permissions=json.dumps(data.permissions, ensure_ascii=False) if data.permissions else None,
is_active=data.is_active,
)
db.add(group)
await db.commit()
await db.refresh(group)
# Write members
await _sync_members(db, group.id, data.readonly_instance_ids, data.write_instance_ids)
await db.commit()
return await _serialize_group_detail(group, db)
@router.get("/{group_id}", response_model=InstanceGroupDetailResponse)
async def get_instance_group(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
return await _serialize_group_detail(group, db)
@router.put("/{group_id}", response_model=InstanceGroupDetailResponse)
async def update_instance_group(
group_id: int,
data: InstanceGroupUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
update_data = data.model_dump(exclude_unset=True)
if "name" in update_data:
# Check uniqueness
existing = await db.execute(
select(InstanceGroup).where(
InstanceGroup.name == update_data["name"],
InstanceGroup.id != group_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="权限组名称已存在")
# Handle legacy permissions field
if "permissions" in update_data:
update_data["permissions"] = (
json.dumps(update_data["permissions"], ensure_ascii=False)
if update_data["permissions"]
else None
)
# Handle new allowed_databases field
if "allowed_databases" in update_data:
update_data["allowed_databases"] = (
json.dumps(update_data["allowed_databases"], ensure_ascii=False)
if update_data["allowed_databases"]
else None
)
# Extract member lists before setting attributes
ro_ids = update_data.pop("readonly_instance_ids", None)
wr_ids = update_data.pop("write_instance_ids", None)
for key, value in update_data.items():
setattr(group, key, value)
await db.commit()
await db.refresh(group)
# Update members if provided
if ro_ids is not None or wr_ids is not None:
await _sync_members(
db, group_id,
ro_ids if ro_ids is not None else [],
wr_ids if wr_ids is not None else [],
)
await db.commit()
return await _serialize_group_detail(group, db)
@router.delete("/{group_id}")
async def delete_instance_group(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
# Delete members first
await db.execute(
sa_delete(instance_group_members).where(
instance_group_members.c.group_id == group_id
)
)
await db.delete(group)
await db.commit()
return {"message": "权限组已删除"}
@router.post("/{group_id}/instances")
async def assign_instances(
group_id: int,
data: InstanceAssignRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
for inst_id in data.instance_ids:
inst_result = await db.execute(
select(Instance).where(Instance.id == inst_id)
)
inst = inst_result.scalar_one_or_none()
if inst:
# Check for duplicate
existing = await db.execute(
select(instance_group_members).where(
instance_group_members.c.group_id == group_id,
instance_group_members.c.instance_id == inst_id,
instance_group_members.c.access_type == data.access_type,
)
)
if not existing.first():
await db.execute(
instance_group_members.insert().values(
group_id=group_id,
instance_id=inst_id,
access_type=data.access_type,
)
)
await db.commit()
return {"message": "实例已关联"}
@router.delete("/{group_id}/instances/{instance_id}")
async def remove_instance(
group_id: int,
instance_id: int,
access_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
conditions = [
instance_group_members.c.group_id == group_id,
instance_group_members.c.instance_id == instance_id,
]
if access_type:
conditions.append(instance_group_members.c.access_type == access_type)
result = await db.execute(
sa_delete(instance_group_members).where(*conditions)
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="实例未关联该权限组")
await db.commit()
return {"message": "实例已取消关联"}
@router.get("/{group_id}/users")
async def list_group_users(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
# Query user_groups for permissions override
ug_result = await db.execute(
select(user_groups).where(user_groups.c.group_id == group_id)
)
ug_rows = ug_result.all()
perm_map = {}
for row in ug_rows:
perms = json.loads(row.permissions) if row.permissions else None
perm_map[row.user_id] = perms
users_data = []
for u in (group.users or []):
users_data.append({
"id": u.id,
"username": u.username,
"display_name": u.display_name,
"role": u.role,
"permissions": perm_map.get(u.id),
})
return users_data
@router.post("/{group_id}/users")
async def assign_users(
group_id: int,
data: UserAssignRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
perm_json = json.dumps(data.permissions, ensure_ascii=False) if data.permissions else None
for user_id in data.user_ids:
# Check if already assigned
check = await db.execute(
select(user_groups).where(
user_groups.c.user_id == user_id,
user_groups.c.group_id == group_id,
)
)
if not check.first():
await db.execute(
user_groups.insert().values(
user_id=user_id,
group_id=group_id,
permissions=perm_json,
)
)
await db.commit()
return {"message": "用户已分配"}
@router.put("/users/{user_id}/groups")
async def update_user_groups(
user_id: int,
data: UserGroupUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
user_result = await db.execute(
select(User).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# Remove existing group associations
await db.execute(
user_groups.delete().where(user_groups.c.user_id == user_id)
)
# Add new ones
for gid in data.group_ids:
group_result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == gid)
)
if group_result.scalar_one_or_none():
await db.execute(
user_groups.insert().values(user_id=user_id, group_id=gid)
)
await db.commit()
return {"message": "用户权限组已更新"}