feat: 实例权限组系统 — 完整CRUD + 实例/用户关联 + 前端管理页面

后端:
- InstanceGroup 模型 (name/db_type/permissions JSON/is_active)
- user_groups 关联表 (用户-权限组 M2M + 权限覆盖)
- Instance 模型增加 group_id FK
- User 模型增加 groups 关系
- API: CRUD + 实例关联 + 用户分配 + 权限组过滤
- 15 个新测试 (CRUD/关联/权限检查/参数校验) — 217/217 passing

前端:
- InstanceGroupPage: 左右布局(列表+详情)
- 权限配置: 5类17项权限勾选 (查询/DDL/工单/数据操作/管理)
- 实例关联/用户关联弹框
- 侧边栏增加权限组菜单 (数据源分组下)
- 路由 + 权限注册
This commit is contained in:
logan committed 2026-05-23 00:09:34 +08:00
1 parent 836ea97ee5
commit 765d27f1e3
15 files changed
+1283 -1

No files matched your search

+1
View File
@@ -9,6 +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.ticket import Ticket
from app.models.audit_log import AuditLog
from app.models.audit_rule import AuditRule
+291
View File
@@ -0,0 +1,291 @@
import pytest
import pytest_asyncio
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 tests.conftest import auth_header
@pytest.mark.asyncio
async def test_create_instance_group(client: AsyncClient, admin_token: str):
resp = await client.post(
"/api/v1/instance-groups",
json={
"name": "生产MySQL组",
"description": "生产环境MySQL实例",
"db_type": "mysql",
"permissions": ["query:select", "query:insert", "ddl:create_table"],
"is_active": True,
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "生产MySQL组"
assert data["db_type"] == "mysql"
assert "query:select" in data["permissions"]
@pytest.mark.asyncio
async def test_create_instance_group_duplicate_name(client: AsyncClient, admin_token: str):
payload = {"name": "重复组", "db_type": "mysql"}
resp1 = await client.post("/api/v1/instance-groups", json=payload, headers=auth_header(admin_token))
assert resp1.status_code == 201
resp2 = await client.post("/api/v1/instance-groups", json=payload, headers=auth_header(admin_token))
assert resp2.status_code == 400
@pytest.mark.asyncio
async def test_list_instance_groups(client: AsyncClient, admin_token: str):
# Create two groups
await client.post(
"/api/v1/instance-groups",
json={"name": "组A", "db_type": "mysql"},
headers=auth_header(admin_token),
)
await client.post(
"/api/v1/instance-groups",
json={"name": "组B", "db_type": "redis"},
headers=auth_header(admin_token),
)
resp = await client.get("/api/v1/instance-groups", headers=auth_header(admin_token))
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 2
@pytest.mark.asyncio
async def test_list_instance_groups_filter_db_type(client: AsyncClient, admin_token: str):
await client.post(
"/api/v1/instance-groups",
json={"name": "过滤MySQL", "db_type": "mysql"},
headers=auth_header(admin_token),
)
resp = await client.get(
"/api/v1/instance-groups",
params={"db_type": "mysql"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert all(g["db_type"] == "mysql" for g in data)
@pytest.mark.asyncio
async def test_get_instance_group(client: AsyncClient, admin_token: str):
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "详情组", "db_type": "pg", "permissions": ["query:select"]},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
resp = await client.get(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "详情组"
assert data["permissions"] == ["query:select"]
@pytest.mark.asyncio
async def test_get_instance_group_not_found(client: AsyncClient, admin_token: str):
resp = await client.get("/api/v1/instance-groups/9999", headers=auth_header(admin_token))
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_update_instance_group(client: AsyncClient, admin_token: str):
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"]
resp = await client.put(
f"/api/v1/instance-groups/{group_id}",
json={"name": "更新后", "description": "已更新"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "更新后"
assert data["description"] == "已更新"
@pytest.mark.asyncio
async def test_delete_instance_group(client: AsyncClient, admin_token: str):
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "删除组", "db_type": "redis"},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
resp = await client.delete(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# Verify deleted
get_resp = await client.get(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(admin_token),
)
assert get_resp.status_code == 404
@pytest.mark.asyncio
async def test_assign_instances_to_group(client: AsyncClient, admin_token: str, db):
# Create group
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"]
# Create instance directly
inst = Instance(
name="测试实例1", 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]},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# Verify via group detail
detail_resp = await client.get(
f"/api/v1/instance-groups/{group_id}",
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"])
@pytest.mark.asyncio
async def test_remove_instance_from_group(client: AsyncClient, admin_token: str, db):
# Create group + instance
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)
# Assign first
await client.post(
f"/api/v1/instance-groups/{group_id}/instances",
json={"instance_ids": [inst.id]},
headers=auth_header(admin_token),
)
# Remove
resp = await client.delete(
f"/api/v1/instance-groups/{group_id}/instances/{inst.id}",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_assign_users_to_group(client: AsyncClient, admin_token: str, admin_user):
create_resp = await client.post(
"/api/v1/instance-groups",
json={"name": "用户关联组", "db_type": "all"},
headers=auth_header(admin_token),
)
group_id = create_resp.json()["id"]
resp = await client.post(
f"/api/v1/instance-groups/{group_id}/users",
json={"user_ids": [admin_user.id], "permissions": ["query:select"]},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# Verify via group users
users_resp = await client.get(
f"/api/v1/instance-groups/{group_id}/users",
headers=auth_header(admin_token),
)
assert users_resp.status_code == 200
users_data = users_resp.json()
assert any(u["id"] == admin_user.id for u in users_data)
@pytest.mark.asyncio
async def test_update_user_groups(client: AsyncClient, admin_token: str, admin_user):
# Create two groups
g1 = await client.post(
"/api/v1/instance-groups",
json={"name": "用户组1", "db_type": "mysql"},
headers=auth_header(admin_token),
)
g2 = await client.post(
"/api/v1/instance-groups",
json={"name": "用户组2", "db_type": "redis"},
headers=auth_header(admin_token),
)
g1_id = g1.json()["id"]
g2_id = g2.json()["id"]
resp = await client.put(
f"/api/v1/instance-groups/users/{admin_user.id}/groups",
json={"group_ids": [g1_id, g2_id]},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_non_admin_cannot_create_group(client: AsyncClient, developer_token: str):
resp = await client.post(
"/api/v1/instance-groups",
json={"name": "非管理员组", "db_type": "mysql"},
headers=auth_header(developer_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_non_admin_cannot_delete_group(client: AsyncClient, admin_token: str, developer_token: str):
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"]
resp = await client.delete(
f"/api/v1/instance-groups/{group_id}",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_group_db_type_validation(client: AsyncClient, admin_token: str):
resp = await client.post(
"/api/v1/instance-groups",
json={"name": "无效类型", "db_type": "oracle"},
headers=auth_header(admin_token),
)
assert resp.status_code == 422