mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
fix(usage): session touch 独立提交避免行锁阻塞 & 管理员页面顺序加载降低并发压力
后端: 将 session touch 的 commit 从请求事务中分离,防止管理员 usage 页面的长查询持有 user_sessions 行锁阻塞后续请求。touch_session 改为 返回 bool 以支持按需提交。 前端: 管理员 Usage 页面将并行 API 调用改为顺序加载,优先显示记录表格, 统计面板在后台异步刷新,避免瞬时并发打满后端 worker。loadRecords 支持 传入 dateRange 参数确保时间范围一致性。
This commit is contained in:
@@ -821,7 +821,7 @@ class TestPipelineAdminAuth:
|
||||
"src.api.base.pipeline.SessionService.get_active_session",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch("src.api.base.pipeline.SessionService.touch_session"),
|
||||
patch("src.api.base.pipeline.SessionService.touch_session", return_value=True),
|
||||
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
||||
):
|
||||
user, management_token = await pipeline._authenticate_admin(mock_request, mock_db)
|
||||
@@ -830,6 +830,7 @@ class TestPipelineAdminAuth:
|
||||
assert management_token is None
|
||||
assert mock_request.state.user_id == "admin-123"
|
||||
assert mock_request.state.user_session_id == "session-123"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_admin_lowercase_bearer(self, pipeline: ApiRequestPipeline) -> None:
|
||||
@@ -871,7 +872,7 @@ class TestPipelineAdminAuth:
|
||||
"src.api.base.pipeline.SessionService.get_active_session",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch("src.api.base.pipeline.SessionService.touch_session"),
|
||||
patch("src.api.base.pipeline.SessionService.touch_session", return_value=True),
|
||||
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
||||
):
|
||||
user, management_token = await pipeline._authenticate_admin(mock_request, mock_db)
|
||||
@@ -920,6 +921,110 @@ class TestPipelineAdminAuth:
|
||||
with pytest.raises(HTTPException, match="登录会话已失效,请重新登录"):
|
||||
await pipeline._authenticate_admin(mock_request, mock_db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_admin_rollback_on_session_touch_commit_failure(
|
||||
self, pipeline: ApiRequestPipeline
|
||||
) -> None:
|
||||
created_at = datetime.now(timezone.utc)
|
||||
mock_session = MagicMock()
|
||||
mock_session.id = "session-123"
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "admin-123"
|
||||
mock_user.is_active = True
|
||||
mock_user.is_deleted = False
|
||||
mock_user.role = UserRole.ADMIN
|
||||
mock_user.email = "admin@example.com"
|
||||
mock_user.created_at = created_at
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {
|
||||
"authorization": "Bearer valid-token",
|
||||
"X-Client-Device-Id": "device-admin-123",
|
||||
}
|
||||
mock_request.state = MagicMock()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
||||
mock_db.commit.side_effect = RuntimeError("lock timeout")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
pipeline.auth_service,
|
||||
"verify_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"user_id": "admin-123",
|
||||
"created_at": created_at.isoformat(),
|
||||
"session_id": "session-123",
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"src.api.base.pipeline.SessionService.get_active_session",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch("src.api.base.pipeline.SessionService.touch_session", return_value=True),
|
||||
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
||||
):
|
||||
user, management_token = await pipeline._authenticate_admin(mock_request, mock_db)
|
||||
|
||||
assert user == mock_user
|
||||
assert management_token is None
|
||||
mock_db.rollback.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_admin_skips_commit_when_session_touch_not_needed(
|
||||
self, pipeline: ApiRequestPipeline
|
||||
) -> None:
|
||||
created_at = datetime.now(timezone.utc)
|
||||
mock_session = MagicMock()
|
||||
mock_session.id = "session-123"
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "admin-123"
|
||||
mock_user.is_active = True
|
||||
mock_user.is_deleted = False
|
||||
mock_user.role = UserRole.ADMIN
|
||||
mock_user.email = "admin@example.com"
|
||||
mock_user.created_at = created_at
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {
|
||||
"authorization": "Bearer valid-token",
|
||||
"X-Client-Device-Id": "device-admin-123",
|
||||
}
|
||||
mock_request.state = MagicMock()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
pipeline.auth_service,
|
||||
"verify_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"user_id": "admin-123",
|
||||
"created_at": created_at.isoformat(),
|
||||
"session_id": "session-123",
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"src.api.base.pipeline.SessionService.get_active_session",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch(
|
||||
"src.api.base.pipeline.SessionService.touch_session",
|
||||
return_value=False,
|
||||
),
|
||||
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
||||
):
|
||||
user, management_token = await pipeline._authenticate_admin(mock_request, mock_db)
|
||||
|
||||
assert user == mock_user
|
||||
assert management_token is None
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
|
||||
class TestPipelineUserAuth:
|
||||
"""测试普通用户 JWT 认证"""
|
||||
@@ -967,7 +1072,7 @@ class TestPipelineUserAuth:
|
||||
"src.api.base.pipeline.SessionService.get_active_session",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch("src.api.base.pipeline.SessionService.touch_session"),
|
||||
patch("src.api.base.pipeline.SessionService.touch_session", return_value=True),
|
||||
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
||||
):
|
||||
user, management_token = await pipeline._authenticate_user(mock_request, mock_db)
|
||||
@@ -976,6 +1081,7 @@ class TestPipelineUserAuth:
|
||||
assert user == mock_user
|
||||
assert management_token is None
|
||||
assert mock_request.state.user_session_id == "session-456"
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_rejects_legacy_token_without_session_id(
|
||||
@@ -1059,3 +1165,55 @@ class TestPipelineUserAuth:
|
||||
):
|
||||
with pytest.raises(HTTPException, match="缺少或无效的设备标识"):
|
||||
await pipeline._authenticate_user(mock_request, mock_db)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_user_skips_commit_when_session_touch_not_needed(
|
||||
self, pipeline: ApiRequestPipeline
|
||||
) -> None:
|
||||
created_at = datetime.now(timezone.utc)
|
||||
mock_session = MagicMock()
|
||||
mock_session.id = "session-456"
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = "user-123"
|
||||
mock_user.is_active = True
|
||||
mock_user.is_deleted = False
|
||||
mock_user.email = "user@example.com"
|
||||
mock_user.created_at = created_at
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {
|
||||
"authorization": "Bearer valid-token",
|
||||
"X-Client-Device-Id": "device-user-456",
|
||||
}
|
||||
mock_request.state = MagicMock()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_user
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
pipeline.auth_service,
|
||||
"verify_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"user_id": "user-123",
|
||||
"created_at": created_at.isoformat(),
|
||||
"session_id": "session-456",
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"src.api.base.pipeline.SessionService.get_active_session",
|
||||
return_value=mock_session,
|
||||
),
|
||||
patch(
|
||||
"src.api.base.pipeline.SessionService.touch_session",
|
||||
return_value=False,
|
||||
),
|
||||
patch("src.api.base.pipeline.SessionService.assert_session_device_matches"),
|
||||
):
|
||||
user, management_token = await pipeline._authenticate_user(mock_request, mock_db)
|
||||
|
||||
assert user == mock_user
|
||||
assert management_token is None
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
@@ -42,7 +42,18 @@ def _make_db_session() -> Session:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine, tables=[User.__table__, UserSession.__table__])
|
||||
session_factory = sessionmaker(bind=engine)
|
||||
return session_factory()
|
||||
db = session_factory()
|
||||
db.info["test_engine"] = engine
|
||||
return db
|
||||
|
||||
|
||||
def _close_db_session(db: Session) -> None:
|
||||
engine = db.info.pop("test_engine", None)
|
||||
try:
|
||||
db.close()
|
||||
finally:
|
||||
if engine is not None:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _make_user(db: Session, *, user_id: str = "user-1") -> User:
|
||||
@@ -224,6 +235,55 @@ def test_set_refresh_token_stores_previous_hash() -> None:
|
||||
assert session.refresh_token_hash != original_hash
|
||||
|
||||
|
||||
def test_touch_session_skips_recent_activity() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
session = UserSession(
|
||||
user_id="user-1",
|
||||
client_device_id="device-1",
|
||||
refresh_token_hash="",
|
||||
expires_at=now + timedelta(days=7),
|
||||
last_seen_at=now,
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="old-agent",
|
||||
)
|
||||
|
||||
touched = SessionService.touch_session(
|
||||
session,
|
||||
client_ip="192.168.0.1",
|
||||
user_agent="new-agent",
|
||||
)
|
||||
|
||||
assert touched is False
|
||||
assert session.last_seen_at == now
|
||||
assert session.ip_address == "127.0.0.1"
|
||||
assert session.user_agent == "old-agent"
|
||||
|
||||
|
||||
def test_touch_session_updates_stale_session() -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
last_seen_at = now - timedelta(minutes=10)
|
||||
session = UserSession(
|
||||
user_id="user-1",
|
||||
client_device_id="device-1",
|
||||
refresh_token_hash="",
|
||||
expires_at=now + timedelta(days=7),
|
||||
last_seen_at=last_seen_at,
|
||||
ip_address="127.0.0.1",
|
||||
user_agent="old-agent",
|
||||
)
|
||||
|
||||
touched = SessionService.touch_session(
|
||||
session,
|
||||
client_ip="192.168.0.1",
|
||||
user_agent="new-agent",
|
||||
)
|
||||
|
||||
assert touched is True
|
||||
assert session.last_seen_at is not None and session.last_seen_at > last_seen_at
|
||||
assert session.ip_address == "192.168.0.1"
|
||||
assert session.user_agent == "new-agent"
|
||||
|
||||
|
||||
def test_get_session_for_user_can_lock_for_update() -> None:
|
||||
expected = object()
|
||||
|
||||
@@ -299,7 +359,7 @@ def test_create_session_session_limit_ignores_expired_sessions() -> None:
|
||||
assert len(active_sessions) == MAX_SESSIONS_PER_USER
|
||||
assert all(session.revoke_reason != "session_limit_exceeded" for session in active_sessions)
|
||||
finally:
|
||||
db.close()
|
||||
_close_db_session(db)
|
||||
|
||||
|
||||
def test_revoke_all_user_sessions_skips_expired_sessions() -> None:
|
||||
@@ -343,7 +403,7 @@ def test_revoke_all_user_sessions_skips_expired_sessions() -> None:
|
||||
assert active.revoked_at is not None
|
||||
assert expired.revoked_at is None
|
||||
finally:
|
||||
db.close()
|
||||
_close_db_session(db)
|
||||
|
||||
|
||||
def test_list_user_sessions_prunes_old_terminal_sessions() -> None:
|
||||
@@ -405,4 +465,4 @@ def test_list_user_sessions_prunes_old_terminal_sessions() -> None:
|
||||
assert "revoked-old" not in remaining_ids
|
||||
assert "expired-recent" in remaining_ids
|
||||
finally:
|
||||
db.close()
|
||||
_close_db_session(db)
|
||||
|
||||
Reference in New Issue
Block a user