chore: commit local changes

This commit is contained in:
吉鑫升 committed 2026-06-04 19:35:56 +08:00
1 parent c1336ad5b3
commit e6e0832c8a
15 files changed
+291 -24

No files matched your search

@@ -71,6 +71,14 @@ class LocalAuthProvider(AuthProvider):
"""
return await self._repo.find_user_by_email_local_part(local_part)
async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list[User]:
"""Fuzzy-match users by a substring of their email local part.
Backs the admin LLM-metrics username filter — returns every account
whose username contains ``substring`` (case-insensitive).
"""
return await self._repo.find_users_by_email_local_part_like(substring, limit=limit)
async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User:
"""Create a new local user.
@@ -81,6 +81,24 @@ class UserRepository(ABC):
"""
raise NotImplementedError
async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list["User"]:
"""Fuzzy-match users whose email local part *contains* ``substring``.
Backs the admin LLM-metrics username filter (按用户名模糊搜索). Unlike
:meth:`find_user_by_email_local_part` (exact prefix → single account),
this returns every matching account. Case-insensitive substring match
against the part before ``@``. Non-overriding repositories return an
empty list.
Args:
substring: Case-insensitive substring to match within the local part.
limit: Maximum number of users to return.
Returns:
Matching users (may be empty).
"""
return []
@abstractmethod
async def update_user(self, user: User) -> User:
"""Update an existing user.
@@ -109,6 +109,33 @@ class SQLiteUserRepository(UserRepository):
row = result.scalar_one_or_none()
return self._row_to_user(row) if row is not None else None
async def find_users_by_email_local_part_like(self, substring: str, *, limit: int = 500) -> list[User]:
term = (substring or "").strip().lower()
if not term:
return []
# Escape SQL LIKE wildcards so a literal ``_``/``%`` in the query does
# not turn into a wildcard. ``\`` is the chosen escape char.
escaped = term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"%{escaped}%"
# Prefilter in SQL on the whole email (portable across sqlite/postgres/
# mysql), then keep only rows whose *local part* contains the term so a
# domain-only hit (e.g. searching "gmail") doesn't pull unrelated users.
stmt = (
select(UserRow)
.where(func.lower(UserRow.email).like(pattern, escape="\\"))
.order_by(UserRow.created_at.asc())
.limit(max(1, limit))
)
async with self._sf() as session:
result = await session.execute(stmt)
rows = result.scalars().all()
out: list[User] = []
for row in rows:
local = (row.email or "").split("@", 1)[0].lower()
if term in local:
out.append(self._row_to_user(row))
return out
async def update_user(self, user: User) -> User:
async with self._sf() as session:
row = await session.get(UserRow, str(user.id))
@@ -22,7 +22,7 @@ from app.gateway.auth import (
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.csrf_middleware import is_secure_request
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_skill_store
from app.gateway.deps import get_agent_store, get_current_user_from_request, get_local_provider, get_run_store, get_skill_store
logger = logging.getLogger(__name__)
@@ -749,6 +749,7 @@ class UserListItem(BaseModel):
system_role: Literal["admin", "user"]
skill_count: int
agent_count: int
qa_count: int = 0 # 问答次数: number of runs (one run = one question)
@router.get("/users", response_model=list[UserListItem])
@@ -757,11 +758,19 @@ async def list_all_users(
skill_store: SkillStore = Depends(get_skill_store),
agent_store: AgentStore = Depends(get_agent_store),
):
"""List all users with their skill and agent counts. Admin only."""
"""List all users with their skill, agent and Q&A counts. Admin only."""
user = await get_current_user_from_request(request)
if user.system_role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
users = await get_local_provider().list_users()
# One GROUP BY query for everyone's 问答次数, so the list stays a single
# aggregate read instead of a per-user count. Best-effort: if the run store
# is unavailable the column simply renders as 0 rather than failing the list.
try:
qa_counts = await get_run_store(request).count_by_user()
except Exception:
logger.warning("Failed to load per-user run counts for user list", exc_info=True)
qa_counts = {}
result = []
for u in users:
uid = str(u.id)
@@ -772,6 +781,7 @@ async def list_all_users(
system_role=u.system_role,
skill_count=await skill_store.count_owned(uid),
agent_count=await agent_store.count_owned(uid),
qa_count=qa_counts.get(uid, 0),
)
)
return result
@@ -121,6 +121,26 @@ async def _resolve_user_name_to_id(name: str | None) -> str | None:
return str(user.id) if user is not None else None
async def _resolve_user_names_to_ids(name: str | None) -> list[str]:
"""Fuzzy-resolve a username fragment to every matching account's UUID.
Powers the 按用户名模糊搜索 filter: typing part of a username matches all
users whose email local part contains it. Returns an empty list when the
fragment matches nobody, so the caller can short-circuit to zero rows.
"""
if not name or not name.strip():
return []
try:
provider = get_local_provider()
except Exception:
return []
try:
users = await provider.find_users_by_email_local_part_like(name.strip())
except Exception:
return []
return [str(u.id) for u in users]
async def _attach_usernames(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Resolve each row's ``user_id`` UUID to a friendly ``user_name``.
@@ -161,9 +181,11 @@ def _build_query(
until: str | None,
limit: int,
offset: int,
user_ids: list[str] | None = None,
) -> LlmMetricsQuery:
return LlmMetricsQuery(
user_id=user_id or None,
user_ids=user_ids or None,
model_name=model_name or None,
status=status or None,
since=_parse_dt(since),
@@ -187,14 +209,15 @@ async def list_llm_metrics(
) -> LlmMetricsListResponse:
await _require_admin(request)
store = _require_store(request)
user_ids: list[str] | None = None
if not user_id and user_name:
resolved = await _resolve_user_name_to_id(user_name)
if resolved is None:
# Username unknown → no rows; short-circuit instead of a global scan.
user_ids = await _resolve_user_names_to_ids(user_name)
if not user_ids:
# No username matches → no rows; short-circuit the global scan.
return LlmMetricsListResponse(items=[], total=0, limit=limit, offset=offset)
user_id = resolved
query = _build_query(
user_id=user_id,
user_ids=user_ids,
model_name=model_name,
status=status,
since=since,
@@ -236,14 +259,13 @@ async def export_llm_metrics(
"""Stream the full filtered metric set as an ``.xlsx`` file."""
await _require_admin(request)
store = _require_store(request)
user_ids: list[str] | None = None
if not user_id and user_name:
resolved = await _resolve_user_name_to_id(user_name)
if resolved is None:
# No matching user — return an empty xlsx so the user still gets a
# file (matching the list view's empty state).
user_id = "__no_such_user__"
else:
user_id = resolved
user_ids = await _resolve_user_names_to_ids(user_name)
if not user_ids:
# No matching user — sentinel id that matches nothing, so the export
# still produces an empty xlsx (matching the list view's empty state).
user_ids = ["__no_such_user__"]
try:
from openpyxl import Workbook
@@ -252,6 +274,7 @@ async def export_llm_metrics(
query = _build_query(
user_id=user_id,
user_ids=user_ids,
model_name=model_name,
status=status,
since=since,