refactor: 权限组从一组一类型改为一组同时包含readonly+write实例

- 新增 instance_group_members 中间表关联权限组和实例
- InstanceGroup 模型移除旧的 instances relationship,改为通过中间表查询
- Instance 模型保留 group_id FK(兼容)但移除 group relationship
- Schema 新增 readonly_instance_ids / write_instance_ids 字段
- InstanceGroupResponse 改为 readonly_instance_count + write_instance_count
- InstanceGroupDetailResponse 返回 readonly_instances + write_instances
- InstanceAssignRequest 新增 access_type 参数
- API create/update 支持同步写入 instance_group_members
- role_permissions 服务更新为使用中间表查询
- 新增迁移 SQL 并应用到 sqlite3.db
- 测试全部通过 (20/20)
This commit is contained in:
logan committed 2026-05-24 20:03:55 +08:00
1 parent 40868c61bf
commit 39d9600e82
8 files changed
+418 -97

No files matched your search

+130 -44
View File
@@ -2,60 +2,79 @@ import json
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func as sa_func
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
from app.models.instance_group import InstanceGroup, user_groups, instance_group_members
from app.schemas.instance_group import (
InstanceGroupCreate, InstanceGroupUpdate,
InstanceGroupResponse, InstanceGroupDetailResponse,
InstanceAssignRequest, UserAssignRequest, UserGroupUpdateRequest,
InstanceAssignRequest, InstanceRemoveRequest,
UserAssignRequest, UserGroupUpdateRequest,
)
router = APIRouter(prefix="/instance-groups", tags=["权限组管理"])
INSTANCE_FIELDS = ["id", "name", "db_type", "host", "port", "env", "status"]
def _serialize_group(group: InstanceGroup, instance_count: int = 0, user_count: int = 0) -> dict:
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,
# Legacy field
"permissions": group.get_permissions(),
# New fields
"access_type": group.access_type,
"allowed_databases": group.get_allowed_databases(),
"is_active": group.is_active,
"created_at": group.created_at,
"updated_at": group.updated_at,
"instance_count": instance_count,
"readonly_instance_count": ro_count,
"write_instance_count": wr_count,
"user_count": user_count,
}
def _serialize_group_detail(group: InstanceGroup) -> dict:
d = _serialize_group(
group,
instance_count=len(group.instances) if group.instances else 0,
user_count=len(group.users) if group.users else 0,
)
d["instances"] = [
{
"id": inst.id,
"name": inst.name,
"db_type": inst.db_type,
"host": inst.host,
"port": inst.port,
"env": inst.env,
"status": inst.status,
}
for inst in (group.instances or [])
]
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,
@@ -68,6 +87,36 @@ def _serialize_group_detail(group: InstanceGroup) -> dict:
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[InstanceGroupResponse])
async def list_instance_groups(
db_type: Optional[str] = None,
@@ -79,7 +128,7 @@ async def list_instance_groups(
stmt = stmt.where(InstanceGroup.db_type == db_type)
result = await db.execute(stmt)
groups = result.scalars().all()
return [_serialize_group_detail(g) for g in groups]
return [await _serialize_group_detail(g, db) for g in groups]
@router.post("", response_model=InstanceGroupResponse, status_code=201)
@@ -99,21 +148,23 @@ async def create_instance_group(
name=data.name,
description=data.description,
db_type=data.db_type,
# New fields
access_type=data.access_type,
allowed_databases=(
json.dumps(data.allowed_databases, ensure_ascii=False)
if data.allowed_databases
else None
),
# Legacy field (kept for compat)
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)
return _serialize_group(group)
# Write members
await _sync_members(db, group.id, data.readonly_instance_ids, data.write_instance_ids)
await db.commit()
return await _serialize_group(group, db)
@router.get("/{group_id}", response_model=InstanceGroupDetailResponse)
@@ -128,7 +179,7 @@ async def get_instance_group(
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
return _serialize_group_detail(group)
return await _serialize_group_detail(group, db)
@router.put("/{group_id}", response_model=InstanceGroupResponse)
@@ -173,11 +224,25 @@ async def update_instance_group(
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)
return _serialize_group(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(group, db)
@router.delete("/{group_id}")
@@ -193,12 +258,12 @@ async def delete_instance_group(
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
# Detach instances
inst_result = await db.execute(
select(Instance).where(Instance.group_id == group_id)
# Delete members first
await db.execute(
sa_delete(instance_group_members).where(
instance_group_members.c.group_id == group_id
)
)
for inst in inst_result.scalars().all():
inst.group_id = None
await db.delete(group)
await db.commit()
@@ -225,7 +290,22 @@ async def assign_instances(
)
inst = inst_result.scalar_one_or_none()
if inst:
inst.group_id = group_id
# 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": "实例已关联"}
@@ -234,16 +314,22 @@ async def assign_instances(
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")),
):
inst_result = await db.execute(
select(Instance).where(Instance.id == instance_id, Instance.group_id == group_id)
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)
)
inst = inst_result.scalar_one_or_none()
if not inst:
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="实例未关联该权限组")
inst.group_id = None
await db.commit()
return {"message": "实例已取消关联"}