Skip to content

Commit 4189280

Browse files
committed
perf(admin): push file list filters, sort and pagination into SQL
FileService.list_files no longer loads FileCodes.all() (including every text payload) and filters/sorts/paginates in Python. Status, type, health and keyword predicates are translated to SQL, ordering and offset/limit run in SQLite, and only the returned page (<=100 rows) is materialized. build_file_health_summary now uses SQL counts instead of walking every row; healthy/attention are derived from the danger/warning/success partition. Equivalence is covered by tests/test_admin_query_pushdown.py, which compares every filter/sort result against the previous per-item Python rules. 10k-row benchmark: list_files 1629ms/29.6MB -> 24.6ms/0.1MB; dashboard health summary 1483ms/29MB -> 13.8ms/<0.1MB.
1 parent 3d2c8ec commit 4189280

4 files changed

Lines changed: 395 additions & 86 deletions

File tree

‎apps/admin/services.py‎

Lines changed: 175 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,48 @@
1818
)
1919
from fastapi import HTTPException
2020
from core.utils import get_now
21+
from tortoise.expressions import Q, RawSQL
22+
from tortoise.functions import Sum
2123

24+
from apps.base.quota import owned_storage_queryset
25+
26+
27+
def expired_file_q(now: datetime) -> Q:
28+
"""SQL version of FileCodes.is_expired:
29+
(expired_count < 0 AND expired_at IS NOT NULL AND expired_at < now) OR expired_count = 0
30+
"""
31+
return (
32+
Q(expired_count__lt=0, expired_at__isnull=False, expired_at__lt=now)
33+
| Q(expired_count=0)
34+
)
35+
36+
37+
def expires_soon_q(now: datetime) -> Q:
38+
"""0 < (expired_at - now) <= 24h(与 _build_file_status_insights 的 expires_soon 一致)。"""
39+
return Q(expired_at__gt=now) & Q(expired_at__lte=now + timedelta(days=1))
40+
41+
42+
def not_expires_soon_q(now: datetime) -> Q:
43+
"""expires_soon_q 的补集;显式处理 NULL,避免 NOT NULL 三值逻辑丢行。"""
44+
return (
45+
Q(expired_at__isnull=True)
46+
| Q(expired_at__lte=now)
47+
| Q(expired_at__gt=now + timedelta(days=1))
48+
)
49+
50+
51+
def _missing_download_source_q() -> Q:
52+
"""无文本且无文件路径/对象名 = 存储元数据不完整(can_download 为假)。"""
53+
return (
54+
Q(text__isnull=True)
55+
& (Q(file_path__isnull=True) | Q(file_path=""))
56+
& (Q(uuid_file_name__isnull=True) | Q(uuid_file_name=""))
57+
)
58+
59+
60+
def danger_file_q(now: datetime) -> Q:
61+
"""severity == danger:已过期、次数耗尽,或缺少可下载来源。"""
62+
return expired_file_q(now) | _missing_download_source_q()
2263

2364

2465
class FileService:
@@ -457,6 +498,18 @@ async def apply_files_policy_action(
457498
"failed": failed,
458499
}
459500

501+
SQL_SORT_FIELDS = {
502+
"created_at": "created_at",
503+
"createdat": "created_at",
504+
"expired_at": "expired_at",
505+
"expiredat": "expired_at",
506+
"name": "display_name",
507+
"size": "size",
508+
"used_count": "used_count",
509+
"usedcount": "used_count",
510+
"code": "code",
511+
}
512+
460513
async def list_files(
461514
self,
462515
page: int,
@@ -477,45 +530,103 @@ async def list_files(
477530
sort_by = self._normalize_sort_by(sort_by)
478531
reverse = sort_order.strip().lower() != "asc"
479532

480-
all_files = await FileCodes.all()
481533
now = await get_now()
482-
enriched_files = []
483-
summary = {
484-
"total_files": len(all_files),
485-
"active_count": 0,
486-
"expired_count": 0,
487-
"text_count": 0,
488-
"file_count": 0,
489-
"chunked_count": 0,
490-
**self._empty_health_summary(),
491-
"storage_used": sum(file_code.size for file_code in all_files if not is_local_ref(file_code)),
492-
"used_count": sum(file_code.used_count for file_code in all_files),
534+
# 过滤、排序、分页全部下推 SQL;text 只为当前页的 ≤100 条加载,
535+
# 关键词命中的 text 也由 SQL LIKE 判定,不再整表载入。
536+
queryset = FileCodes.all()
537+
if keyword or sort_by == "name":
538+
# name 是 prefix + suffix 的派生值,SQLite 用 || 拼接排序/匹配
539+
queryset = FileCodes.annotate(display_name=RawSQL("prefix || suffix"))
540+
queryset = queryset.filter(
541+
self._build_list_filter_q(keyword, status, file_type, health, now)
542+
)
543+
total = await queryset.count()
544+
summary = await self.build_list_summary(now=now)
545+
546+
order_field = self.SQL_SORT_FIELDS.get(sort_by, "created_at")
547+
direction = "-" if reverse else ""
548+
# id 作为次级排序:等价于原先内存排序对默认主键序的稳定性
549+
rows = await queryset.order_by(f"{direction}{order_field}", "id").offset(
550+
(page - 1) * size
551+
).limit(size)
552+
files = [await self._build_admin_file_item(file_code, now=now) for file_code in rows]
553+
return files, total, summary
554+
555+
def _build_list_filter_q(
556+
self, keyword: str, status: str, file_type: str, health: str, now: datetime
557+
) -> Q:
558+
query = Q()
559+
if status == "active":
560+
query &= ~expired_file_q(now)
561+
elif status == "expired":
562+
query &= expired_file_q(now)
563+
if file_type == "text":
564+
query &= Q(text__isnull=False)
565+
elif file_type == "file":
566+
query &= Q(text__isnull=True)
567+
elif file_type == "chunked":
568+
query &= Q(is_chunked=True)
569+
if health:
570+
query &= self._health_filter_q(health, now)
571+
if keyword:
572+
# 与旧的内存匹配对齐:code/name/prefix/suffix/file_hash/text 子串命中。
573+
# icontains 只对 ASCII 大小写不敏感(SQLite UPPER 限制),中文不受影响。
574+
query &= (
575+
Q(code__icontains=keyword)
576+
| Q(prefix__icontains=keyword)
577+
| Q(suffix__icontains=keyword)
578+
| Q(file_hash__icontains=keyword)
579+
| Q(text__icontains=keyword)
580+
| Q(display_name__icontains=keyword)
581+
)
582+
return query
583+
584+
def _health_filter_q(self, health: str, now: datetime) -> Q:
585+
"""health 过滤的 SQL 等价实现,语义与 _match_admin_file_health 一致。"""
586+
danger = danger_file_q(now)
587+
soon = expires_soon_q(now)
588+
mapping = {
589+
"attention": danger | soon,
590+
"danger": danger,
591+
"warning": ~danger & soon,
592+
"expired": expired_file_q(now),
593+
"expiring_soon": soon,
594+
"storage_issue": _missing_download_source_q(),
595+
"never_retrieved": Q(used_count=0),
596+
"healthy": ~danger & not_expires_soon_q(now),
597+
"permanent": ~danger
598+
& not_expires_soon_q(now)
599+
& Q(expired_at__isnull=True)
600+
& Q(expired_count__lt=0),
493601
}
602+
return mapping.get(health, Q())
494603

495-
for file_code in all_files:
496-
item = await self._build_admin_file_item(file_code, now=now)
497-
if item["is_expired"]:
498-
summary["expired_count"] += 1
499-
else:
500-
summary["active_count"] += 1
501-
if item["is_text"]:
502-
summary["text_count"] += 1
503-
else:
504-
summary["file_count"] += 1
505-
if item["is_chunked"]:
506-
summary["chunked_count"] += 1
507-
self._accumulate_health_summary(summary, item)
508-
509-
if not self._match_admin_file(item, keyword, status, file_type, health):
510-
continue
511-
enriched_files.append(item)
512-
513-
enriched_files.sort(
514-
key=lambda item: self._get_sort_value(item, sort_by),
515-
reverse=reverse,
516-
)
517-
offset = (page - 1) * size
518-
return enriched_files[offset : offset + size], len(enriched_files), summary
604+
async def build_list_summary(self, now: Optional[datetime] = None) -> dict[str, Any]:
605+
if now is None:
606+
now = await get_now()
607+
total_files = await FileCodes.all().count()
608+
expired_count = await FileCodes.filter(expired_file_q(now)).count()
609+
text_count = await FileCodes.filter(text__isnull=False).count()
610+
chunked_count = await FileCodes.filter(is_chunked=True).count()
611+
storage_used = (
612+
await owned_storage_queryset()
613+
.annotate(total=Sum("size"))
614+
.values("total")
615+
)[0]["total"] or 0
616+
used_count = (
617+
await FileCodes.all().annotate(total=Sum("used_count")).values("total")
618+
)[0]["total"] or 0
619+
return {
620+
"total_files": total_files,
621+
"active_count": total_files - expired_count,
622+
"expired_count": expired_count,
623+
"text_count": text_count,
624+
"file_count": total_files - text_count,
625+
"chunked_count": chunked_count,
626+
**await self.build_file_health_summary(now=now),
627+
"storage_used": storage_used,
628+
"used_count": used_count,
629+
}
519630

520631
def _empty_health_summary(self) -> dict[str, int]:
521632
return {
@@ -529,39 +640,37 @@ def _empty_health_summary(self) -> dict[str, int]:
529640
"permanent_count": 0,
530641
}
531642

532-
def _accumulate_health_summary(self, summary: dict[str, Any], item: dict[str, Any]) -> None:
533-
status_insights = item.get("status_insights") or {}
534-
reasons = status_insights.get("reasons") or []
535-
severity = status_insights.get("severity")
536-
state = status_insights.get("state")
643+
async def build_file_health_summary(self, now: Optional[datetime] = None) -> dict[str, int]:
644+
"""健康摘要全部用 SQL 计数,避免为统计而整表载入(含 text 大字段)。
537645
538-
if severity in {"danger", "warning"}:
539-
summary["health_attention_count"] += 1
540-
if severity == "danger":
541-
summary["health_danger_count"] += 1
542-
if severity == "warning":
543-
summary["health_warning_count"] += 1
544-
if severity == "success":
545-
summary["healthy_count"] += 1
546-
if state == "permanent":
547-
summary["permanent_count"] += 1
548-
if "expires_soon" in reasons:
549-
summary["expiring_soon_count"] += 1
550-
if "storage_metadata_incomplete" in reasons:
551-
summary["storage_issue_count"] += 1
552-
if "never_retrieved" in reasons:
553-
summary["never_retrieved_count"] += 1
554-
555-
async def build_file_health_summary(
556-
self, file_codes: list[FileCodes], now: Optional[datetime] = None
557-
) -> dict[str, int]:
646+
severity 三态互斥:danger / warning / success,因此
647+
healthy_count = total - danger - warning,attention = danger + warning。
648+
"""
558649
if now is None:
559650
now = await get_now()
560-
summary = self._empty_health_summary()
561-
for file_code in file_codes:
562-
item = await self._build_admin_file_item(file_code, now=now)
563-
self._accumulate_health_summary(summary, item)
564-
return summary
651+
danger = danger_file_q(now)
652+
soon = expires_soon_q(now)
653+
not_soon = not_expires_soon_q(now)
654+
total = await FileCodes.all().count()
655+
danger_count = await FileCodes.filter(danger).count()
656+
warning_count = await FileCodes.filter(~danger & soon).count()
657+
return {
658+
"health_attention_count": danger_count + warning_count,
659+
"health_danger_count": danger_count,
660+
"health_warning_count": warning_count,
661+
"healthy_count": total - danger_count - warning_count,
662+
"permanent_count": await FileCodes.filter(
663+
~danger
664+
& not_soon
665+
& Q(expired_at__isnull=True)
666+
& Q(expired_count__lt=0)
667+
).count(),
668+
"expiring_soon_count": await FileCodes.filter(soon).count(),
669+
"storage_issue_count": await FileCodes.filter(
670+
_missing_download_source_q()
671+
).count(),
672+
"never_retrieved_count": await FileCodes.filter(used_count=0).count(),
673+
}
565674

566675
async def _build_admin_file_item(
567676
self, file_code: FileCodes, now: Optional[datetime] = None

‎apps/admin/views.py‎

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88
from typing import Optional
99

1010
from fastapi import APIRouter, Depends, HTTPException
11-
from apps.admin.services import FileService, ConfigService, LocalFileService
11+
from apps.admin.services import (
12+
FileService,
13+
ConfigService,
14+
LocalFileService,
15+
expired_file_q,
16+
)
1217
from apps.admin.dependencies import (
1318
admin_required,
1419
get_admin_session,
@@ -33,7 +38,6 @@
3338
from core.response import APIResponse
3439
from apps.base.models import FileCodes, KeyValue
3540
from apps.base.quota import owned_storage_queryset
36-
from tortoise.expressions import Q
3741
from tortoise.functions import Count, Sum
3842
from apps.admin.dependencies import (
3943
create_token,
@@ -120,15 +124,6 @@ async def build_dashboard_recent_file(file_code: FileCodes) -> dict:
120124
}
121125

122126

123-
def _expired_predicate(now: datetime.datetime) -> Q:
124-
"""SQL version of FileCodes.is_expired:
125-
expired_at IS NOT NULL AND ((expired_count < 0 AND expired_at < now) OR expired_count = 0)
126-
"""
127-
return Q(expired_at__isnull=False) & (
128-
Q(expired_count__lt=0, expired_at__lt=now) | Q(expired_count=0)
129-
)
130-
131-
132127
async def _sum_size(queryset) -> int:
133128
rows = await queryset.annotate(total=Sum("size")).values("total")
134129
return rows[0]["total"] or 0
@@ -142,10 +137,9 @@ async def dashboard(file_service: FileService = Depends(get_file_service)):
142137
yesterday_start = today_start - datetime.timedelta(days=1)
143138
yesterday_end = today_start - datetime.timedelta(microseconds=1)
144139

145-
# 简单计数全部走 SQL 聚合,避免整表载入内存(D3)。
146-
# 健康摘要依赖逐行 rules engine(_build_file_status_insights),保留一次遍历。
140+
# 简单计数与健康摘要全部走 SQL 聚合,避免整表载入内存(D3)。
147141
total_files = await FileCodes.all().count()
148-
expired_count = await FileCodes.filter(_expired_predicate(now)).count()
142+
expired_count = await FileCodes.filter(expired_file_q(now)).count()
149143
all_size = await _sum_size(owned_storage_queryset())
150144
used_count = (
151145
await FileCodes.all().annotate(total=Sum("used_count")).values("total")
@@ -175,9 +169,7 @@ async def dashboard(file_service: FileService = Depends(get_file_service)):
175169
)
176170

177171
recent_file_codes = await FileCodes.all().order_by("-created_at").limit(8)
178-
health_summary = await file_service.build_file_health_summary(
179-
await FileCodes.all(), now=now
180-
)
172+
health_summary = await file_service.build_file_health_summary(now=now)
181173
recent_activities = await file_service.list_admin_activities(limit=8)
182174
return APIResponse(
183175
detail={

‎apps/base/models.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@ class FileCodes(models.Model):
3232
upload_id = fields.CharField(max_length=36, null=True)
3333

3434
async def is_expired(self):
35+
if self.expired_count >= 0:
36+
return self.expired_count <= 0
3537
if self.expired_at is None:
3638
return False
37-
if self.expired_at and self.expired_count < 0:
38-
return self.expired_at < await get_now()
39-
return self.expired_count <= 0
39+
return self.expired_at < await get_now()
4040

4141
async def get_file_path(self):
4242
return f"{self.file_path}/{self.uuid_file_name}"

0 commit comments

Comments
 (0)