1818)
1919from fastapi import HTTPException
2020from 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
2465class 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
0 commit comments