From 39d9600e82b407bc456989c8ccdcf722285854b2 Mon Sep 17 00:00:00 2001 From: logan Date: Sun, 24 May 2026 20:03:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=9D=83=E9=99=90=E7=BB=84?= =?UTF-8?q?=E4=BB=8E=E4=B8=80=E7=BB=84=E4=B8=80=E7=B1=BB=E5=9E=8B=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=B8=80=E7=BB=84=E5=90=8C=E6=97=B6=E5=8C=85=E5=90=AB?= =?UTF-8?q?readonly+write=E5=AE=9E=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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) --- .../app/api/v1/endpoints/instance_group.py | 174 +++++++++++---- backend/app/models/__init__.py | 2 +- backend/app/models/instance.py | 5 +- backend/app/models/instance_group.py | 18 +- backend/app/schemas/instance_group.py | 28 +-- backend/app/services/role_permissions.py | 81 ++++--- backend/tests/conftest.py | 2 +- backend/tests/test_instance_group.py | 205 +++++++++++++++++- 8 files changed, 418 insertions(+), 97 deletions(-) diff --git a/backend/app/api/v1/endpoints/instance_group.py b/backend/app/api/v1/endpoints/instance_group.py index 7e5d5e5..13d52a4 100644 --- a/backend/app/api/v1/endpoints/instance_group.py +++ b/backend/app/api/v1/endpoints/instance_group.py @@ -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": "实例已取消关联"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d038a25..933fbce 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,3 +1,3 @@ -from app.models.instance_group import InstanceGroup, user_groups # noqa: F401 +from app.models.instance_group import InstanceGroup, user_groups, instance_group_members # noqa: F401 from app.models.system_setting import SystemSetting # noqa: F401 from app.models.query_history import QueryHistory, QuickQueryModel # noqa: F401 diff --git a/backend/app/models/instance.py b/backend/app/models/instance.py index e3c4d43..f3ac37d 100644 --- a/backend/app/models/instance.py +++ b/backend/app/models/instance.py @@ -26,12 +26,11 @@ class Instance(Base): excluded_databases = Column(Text, nullable=True) # JSON: ["sys", "information_schema", ...] mask_fields = Column(Text, nullable=True) # JSON: [{table, field, rule}] - # 权限组关联 + # 权限组关联 (legacy FK, kept for compat) group_id = Column(Integer, ForeignKey("instance_groups.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()) - # 关系 - group = relationship("InstanceGroup", back_populates="instances") + # 关系 — legacy group relationship removed; use instance_group_members instead workflow_def = relationship("WorkflowDefinition") diff --git a/backend/app/models/instance_group.py b/backend/app/models/instance_group.py index 61d0119..e4d06dd 100644 --- a/backend/app/models/instance_group.py +++ b/backend/app/models/instance_group.py @@ -3,7 +3,7 @@ from __future__ import annotations import json from typing import List, Optional -from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Table, func +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Table, func, UniqueConstraint from sqlalchemy.orm import relationship from app.core.database import Base @@ -17,6 +17,17 @@ user_groups = Table( Column("permissions", Text, nullable=True), # JSON: 覆盖组默认权限 (legacy, kept for compat) ) +# 实例-权限组关联表(一个权限组可同时包含 readonly 和 write 实例) +instance_group_members = Table( + "instance_group_members", + Base.metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("group_id", Integer, ForeignKey("instance_groups.id", ondelete="CASCADE"), nullable=False), + Column("instance_id", Integer, ForeignKey("instances.id", ondelete="CASCADE"), nullable=False), + Column("access_type", String(20), nullable=False, default="readonly"), # readonly / write + UniqueConstraint("group_id", "instance_id", "access_type", name="uq_group_instance_access"), +) + class InstanceGroup(Base): __tablename__ = "instance_groups" @@ -26,14 +37,13 @@ class InstanceGroup(Base): description = Column(Text, nullable=True) db_type = Column(String(20), nullable=False, default="all") # mysql/redis/pg/mongo/all permissions = Column(Text, nullable=True) # JSON: ["query:select", ...] (legacy, kept for compat) - access_type = Column(String(20), nullable=True, default="readonly") # readonly / write + access_type = Column(String(20), nullable=True, default="readonly") # legacy, kept for compat allowed_databases = Column(Text, nullable=True) # JSON array of database names; empty = all databases is_active = Column(Boolean, default=True) created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) - # 关系 - instances = relationship("Instance", back_populates="group", lazy="selectin") + # 关系 — users 保持不变 users = relationship("User", secondary=user_groups, back_populates="groups", lazy="selectin") # ---- Legacy helpers (kept for backward compatibility) ---- diff --git a/backend/app/schemas/instance_group.py b/backend/app/schemas/instance_group.py index ed79b56..24cbe06 100644 --- a/backend/app/schemas/instance_group.py +++ b/backend/app/schemas/instance_group.py @@ -7,11 +7,6 @@ class InstanceGroupCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) description: Optional[str] = None db_type: str = Field(default="all", pattern=r"^(mysql|redis|pg|mongo|all)$") - access_type: str = Field( - default="readonly", - pattern=r"^(readonly|write)$", - description="权限组类型: readonly(只读查看) / write(写入变更)", - ) allowed_databases: Optional[List[str]] = Field( default=None, description="允许访问的数据库名列表,为空表示关联实例的所有库", @@ -19,17 +14,15 @@ class InstanceGroupCreate(BaseModel): # Legacy field kept for backward compatibility permissions: Optional[List[str]] = None is_active: bool = True + # New: instance ids by access_type + readonly_instance_ids: List[int] = Field(default_factory=list) + write_instance_ids: List[int] = Field(default_factory=list) class InstanceGroupUpdate(BaseModel): name: Optional[str] = Field(default=None, min_length=1, max_length=100) description: Optional[str] = None db_type: Optional[str] = Field(default=None, pattern=r"^(mysql|redis|pg|mongo|all)$") - access_type: Optional[str] = Field( - default=None, - pattern=r"^(readonly|write)$", - description="权限组类型: readonly(只读查看) / write(写入变更)", - ) allowed_databases: Optional[List[str]] = Field( default=None, description="允许访问的数据库名列表,为空表示关联实例的所有库", @@ -37,6 +30,9 @@ class InstanceGroupUpdate(BaseModel): # Legacy field kept for backward compatibility permissions: Optional[List[str]] = None is_active: Optional[bool] = None + # New: instance ids by access_type + readonly_instance_ids: Optional[List[int]] = None + write_instance_ids: Optional[List[int]] = None class InstanceGroupResponse(BaseModel): @@ -47,24 +43,30 @@ class InstanceGroupResponse(BaseModel): # Legacy field permissions: Optional[List[str]] = None # New fields - access_type: Optional[str] = None allowed_databases: Optional[List[str]] = None is_active: bool created_at: Optional[datetime] = None updated_at: Optional[datetime] = None - instance_count: int = 0 + readonly_instance_count: int = 0 + write_instance_count: int = 0 user_count: int = 0 model_config = ConfigDict(from_attributes=True) class InstanceGroupDetailResponse(InstanceGroupResponse): - instances: List[dict] = [] + readonly_instances: List[dict] = [] + write_instances: List[dict] = [] users: List[dict] = [] class InstanceAssignRequest(BaseModel): instance_ids: List[int] = Field(..., min_length=1) + access_type: str = Field(default="readonly", pattern=r"^(readonly|write)$") + + +class InstanceRemoveRequest(BaseModel): + instance_ids: List[int] = Field(..., min_length=1) class UserAssignRequest(BaseModel): diff --git a/backend/app/services/role_permissions.py b/backend/app/services/role_permissions.py index ae73a02..09881a1 100644 --- a/backend/app/services/role_permissions.py +++ b/backend/app/services/role_permissions.py @@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession 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 logger = logging.getLogger(__name__) @@ -132,7 +132,7 @@ async def get_accessible_instances( - admin/dba: 返回所有实例 - auditor: readonly 返回所有,write 返回空 - - developer: 返回其权限组关联的实例 + - developer: 返回其权限组关联的实例(通过 instance_group_members) """ role_config = _get_role_config(user.role) access_value = role_config.get(access_type) @@ -144,27 +144,26 @@ async def get_accessible_instances( if access_value is None: return [] - # "groups" — 查询权限组关联的实例 + # "groups" — 查询权限组关联的实例 via instance_group_members group_ids = await _get_user_group_ids(db, user) if not group_ids: return [] - # 只返回匹配 access_type 的权限组关联的实例 + # Query instance_group_members for the matching access_type result = await db.execute( - select(InstanceGroup).where(InstanceGroup.id.in_(group_ids)) + 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, + ) ) - groups = result.scalars().all() - matched_instance_ids: set = set() - for g in groups: - if g.access_type == access_type: - for inst in (g.instances or []): - matched_instance_ids.add(inst.id) + instance_ids = list({row[0] for row in result.all()}) - if not matched_instance_ids: + if not instance_ids: return [] inst_result = await db.execute( - select(Instance).where(Instance.id.in_(matched_instance_ids)) + select(Instance).where(Instance.id.in_(instance_ids)) ) return list(inst_result.scalars().all()) @@ -195,12 +194,26 @@ async def _get_user_groups_detail(db: AsyncSession, user: User) -> List[dict]: details = [] for g in groups: - instance_ids = [inst.id for inst in (g.instances or [])] + # Get instance IDs by access_type from instance_group_members + ro_result = await db.execute( + select(instance_group_members.c.instance_id).where( + instance_group_members.c.group_id == g.id, + instance_group_members.c.access_type == "readonly", + ) + ) + wr_result = await db.execute( + select(instance_group_members.c.instance_id).where( + instance_group_members.c.group_id == g.id, + instance_group_members.c.access_type == "write", + ) + ) + ro_ids = [row[0] for row in ro_result.all()] + wr_ids = [row[0] for row in wr_result.all()] details.append({ "group_id": g.id, "group_name": g.name, - "access_type": g.access_type, - "instance_ids": instance_ids, + "readonly_instance_ids": ro_ids, + "write_instance_ids": wr_ids, "allowed_databases": g.get_allowed_databases(), }) return details @@ -221,28 +234,44 @@ async def _check_group_access( if not instance: return False + # Public instance (no group_id) — readonly allowed if instance.group_id is None: return access_type == "readonly" group_ids = await _get_user_group_ids(db, user) - if instance.group_id not in group_ids: + + # Check if any of the user's groups contain this instance with matching access_type + result = await db.execute( + select(instance_group_members).where( + instance_group_members.c.instance_id == instance_id, + instance_group_members.c.group_id.in_(group_ids), + instance_group_members.c.access_type == access_type, + ) + ) + member_row = result.first() + if not member_row: + # For readonly access, write access_type also grants readonly + if access_type == "readonly": + wr_result = await db.execute( + select(instance_group_members).where( + instance_group_members.c.instance_id == instance_id, + instance_group_members.c.group_id.in_(group_ids), + instance_group_members.c.access_type == "write", + ) + ) + member_row = wr_result.first() + + if not member_row: return False + # Check allowed_databases from the group group_result = await db.execute( - select(InstanceGroup).where(InstanceGroup.id == instance.group_id) + select(InstanceGroup).where(InstanceGroup.id == member_row.group_id) ) group = group_result.scalar_one_or_none() if not group: return False - # readonly 权限组也可以被 write 请求使用(读+写包含只读) - if access_type == "write" and group.access_type == "readonly": - return False - - # readonly 请求可以用 readonly 或 write 权限组 - if access_type == "readonly" and group.access_type in ("readonly", "write"): - pass # ok - allowed_dbs = group.get_allowed_databases() if not allowed_dbs: return True diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 59f3ddd..4f6294f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -9,7 +9,7 @@ from app.core.database import Base, get_db from app.core.security import hash_password, create_access_token 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.models.ticket import Ticket from app.models.audit_log import AuditLog from app.models.audit_rule import AuditRule diff --git a/backend/tests/test_instance_group.py b/backend/tests/test_instance_group.py index 859ed1e..6d0e6ab 100644 --- a/backend/tests/test_instance_group.py +++ b/backend/tests/test_instance_group.py @@ -5,7 +5,7 @@ from httpx import ASGITransport, AsyncClient from app.core.security import hash_password, create_access_token from app.models.user import User from app.models.instance import Instance -from app.models.instance_group import InstanceGroup +from app.models.instance_group import InstanceGroup, instance_group_members from tests.conftest import auth_header @@ -159,7 +159,7 @@ async def test_assign_instances_to_group(client: AsyncClient, admin_token: str, resp = await client.post( f"/api/v1/instance-groups/{group_id}/instances", - json={"instance_ids": [inst.id]}, + json={"instance_ids": [inst.id], "access_type": "readonly"}, headers=auth_header(admin_token), ) assert resp.status_code == 200 @@ -170,8 +170,75 @@ async def test_assign_instances_to_group(client: AsyncClient, admin_token: str, headers=auth_header(admin_token), ) data = detail_resp.json() - assert data["instance_count"] >= 1 - assert any(i["id"] == inst.id for i in data["instances"]) + assert data["readonly_instance_count"] >= 1 + assert any(i["id"] == inst.id for i in data["readonly_instances"]) + + +@pytest.mark.asyncio +async def test_assign_write_instances_to_group(client: AsyncClient, admin_token: str, db): + create_resp = await client.post( + "/api/v1/instance-groups", + json={"name": "写入实例组", "db_type": "mysql"}, + headers=auth_header(admin_token), + ) + group_id = create_resp.json()["id"] + + inst = Instance( + name="写入实例", db_type="mysql", host="127.0.0.1", + port=3306, username="root", password="test", database="test", + ) + db.add(inst) + await db.commit() + await db.refresh(inst) + + resp = await client.post( + f"/api/v1/instance-groups/{group_id}/instances", + json={"instance_ids": [inst.id], "access_type": "write"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + detail_resp = await client.get( + f"/api/v1/instance-groups/{group_id}", + headers=auth_header(admin_token), + ) + data = detail_resp.json() + assert data["write_instance_count"] >= 1 + assert any(i["id"] == inst.id for i in data["write_instances"]) + + +@pytest.mark.asyncio +async def test_assign_instances_default_readonly(client: AsyncClient, admin_token: str, db): + """Test that access_type defaults to readonly when not specified.""" + create_resp = await client.post( + "/api/v1/instance-groups", + json={"name": "默认只读组", "db_type": "mysql"}, + headers=auth_header(admin_token), + ) + group_id = create_resp.json()["id"] + + inst = Instance( + name="默认只读实例", db_type="mysql", host="127.0.0.1", + port=3306, username="root", password="test", database="test", + ) + db.add(inst) + await db.commit() + await db.refresh(inst) + + # Send without access_type — should default to readonly + resp = await client.post( + f"/api/v1/instance-groups/{group_id}/instances", + json={"instance_ids": [inst.id]}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + detail_resp = await client.get( + f"/api/v1/instance-groups/{group_id}", + headers=auth_header(admin_token), + ) + data = detail_resp.json() + assert data["readonly_instance_count"] >= 1 @pytest.mark.asyncio @@ -195,17 +262,110 @@ async def test_remove_instance_from_group(client: AsyncClient, admin_token: str, # Assign first await client.post( f"/api/v1/instance-groups/{group_id}/instances", - json={"instance_ids": [inst.id]}, + json={"instance_ids": [inst.id], "access_type": "readonly"}, headers=auth_header(admin_token), ) # Remove resp = await client.delete( f"/api/v1/instance-groups/{group_id}/instances/{inst.id}", + params={"access_type": "readonly"}, headers=auth_header(admin_token), ) assert resp.status_code == 200 +@pytest.mark.asyncio +async def test_assign_instances_on_create(client: AsyncClient, admin_token: str, db): + """Test creating a group with readonly_instance_ids and write_instance_ids.""" + inst1 = Instance( + name="创建时只读实例", db_type="mysql", host="127.0.0.1", + port=3306, username="root", password="test", database="test", + ) + inst2 = Instance( + name="创建时写入实例", db_type="mysql", host="127.0.0.1", + port=3307, username="root", password="test", database="test", + ) + db.add(inst1) + db.add(inst2) + await db.commit() + await db.refresh(inst1) + await db.refresh(inst2) + + resp = await client.post( + "/api/v1/instance-groups", + json={ + "name": "创建时关联实例组", + "db_type": "mysql", + "readonly_instance_ids": [inst1.id], + "write_instance_ids": [inst2.id], + }, + headers=auth_header(admin_token), + ) + assert resp.status_code == 201 + data = resp.json() + assert data["readonly_instance_count"] == 1 + assert data["write_instance_count"] == 1 + + # Verify detail + detail = await client.get( + f"/api/v1/instance-groups/{data['id']}", + headers=auth_header(admin_token), + ) + detail_data = detail.json() + assert len(detail_data["readonly_instances"]) == 1 + assert len(detail_data["write_instances"]) == 1 + + +@pytest.mark.asyncio +async def test_update_group_members(client: AsyncClient, admin_token: str, db): + """Test updating a group's instance members.""" + inst1 = Instance( + name="更新成员实例1", db_type="mysql", host="127.0.0.1", + port=3306, username="root", password="test", database="test", + ) + inst2 = Instance( + name="更新成员实例2", db_type="mysql", host="127.0.0.1", + port=3307, username="root", password="test", database="test", + ) + db.add(inst1) + db.add(inst2) + await db.commit() + await db.refresh(inst1) + await db.refresh(inst2) + + create_resp = await client.post( + "/api/v1/instance-groups", + json={ + "name": "更新成员组", + "db_type": "mysql", + "readonly_instance_ids": [inst1.id], + }, + headers=auth_header(admin_token), + ) + group_id = create_resp.json()["id"] + + # Update to replace members + resp = await client.put( + f"/api/v1/instance-groups/{group_id}", + json={ + "readonly_instance_ids": [inst2.id], + "write_instance_ids": [inst1.id], + }, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + + detail = await client.get( + f"/api/v1/instance-groups/{group_id}", + headers=auth_header(admin_token), + ) + data = detail.json() + assert data["readonly_instance_count"] == 1 + assert data["write_instance_count"] == 1 + assert data["readonly_instances"][0]["id"] == inst2.id + assert data["write_instances"][0]["id"] == inst1.id + + @pytest.mark.asyncio async def test_assign_users_to_group(client: AsyncClient, admin_token: str, admin_user): create_resp = await client.post( @@ -289,3 +449,38 @@ async def test_group_db_type_validation(client: AsyncClient, admin_token: str): headers=auth_header(admin_token), ) assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_same_instance_readonly_and_write(client: AsyncClient, admin_token: str, db): + """Test that the same instance can be both readonly and write in the same group.""" + inst = Instance( + name="双重实例", db_type="mysql", host="127.0.0.1", + port=3306, username="root", password="test", database="test", + ) + db.add(inst) + await db.commit() + await db.refresh(inst) + + create_resp = await client.post( + "/api/v1/instance-groups", + json={ + "name": "双重权限组", + "db_type": "mysql", + "readonly_instance_ids": [inst.id], + "write_instance_ids": [inst.id], + }, + headers=auth_header(admin_token), + ) + assert create_resp.status_code == 201 + data = create_resp.json() + assert data["readonly_instance_count"] == 1 + assert data["write_instance_count"] == 1 + + detail = await client.get( + f"/api/v1/instance-groups/{data['id']}", + headers=auth_header(admin_token), + ) + detail_data = detail.json() + assert any(i["id"] == inst.id for i in detail_data["readonly_instances"]) + assert any(i["id"] == inst.id for i in detail_data["write_instances"])