Procházet zdrojové kódy

feat(api): 统一响应格式并重构 API 端点

在 api.v1.__init__ 中添加共享的 ok() 响应封装函数,统一响应结构
移除 documents、export、export_records 模块中重复的 _ok() 辅助函数
更新所有 API 端点,统一使用集中定义的 ok() 函数
将 export.py 中的导入语句移至文件顶部,提升代码组织性
移除 export 端点中对 DocumentNotFoundError 多余的 try-catch 处理
合并 export 处理器中的数据库会话与 Service 初始化逻辑
将 documents.py 中的 math 导入移至模块顶层,去掉内联导入
简化导出响应的构造方式,直接链式调用 model_dump()
确保所有 v1 端点的 API 响应格式保持一致
chensiyu před 2 měsíci
rodič
revize
6aeee225c5

+ 2 - 0
app/api/v1/__init__.py

@@ -0,0 +1,2 @@
1
+def ok(data: dict) -> dict:
2
+    return {"code": 0, "message": "Success", "data": data}

binární
app/api/v1/__pycache__/__init__.cpython-311.pyc


binární
app/api/v1/__pycache__/documents.cpython-311.pyc


binární
app/api/v1/__pycache__/export.cpython-311.pyc


binární
app/api/v1/__pycache__/export_records.cpython-311.pyc


+ 6 - 9
app/api/v1/documents.py

@@ -1,8 +1,10 @@
1
+import math
1 2
 from typing import Literal, Optional
2 3
 
3 4
 from fastapi import APIRouter, Depends, Query
4 5
 from sqlalchemy.ext.asyncio import AsyncSession
5 6
 
7
+from app.api.v1 import ok
6 8
 from app.core.dependencies import get_db
7 9
 from app.schemas.document import (
8 10
     CreateDocumentRequest,
@@ -16,10 +18,6 @@ from app.services.document_service import DocumentService
16 18
 router = APIRouter(prefix="/documents", tags=["Documents"])
17 19
 
18 20
 
19
-def _ok(data: dict) -> dict:
20
-    return {"code": 0, "message": "Success", "data": data}
21
-
22
-
23 21
 # ------------------------------------------------------------------ #
24 22
 # POST /documents  创建文档
25 23
 # ------------------------------------------------------------------ #
@@ -30,7 +28,7 @@ async def create_document(
30 28
 ) -> dict:
31 29
     svc = DocumentService(db)
32 30
     doc = await svc.create_document(body)
33
-    return _ok(
31
+    return ok(
34 32
         {
35 33
             "documentId": doc.id,
36 34
             "title": doc.title,
@@ -60,7 +58,6 @@ async def list_documents(
60 58
         sort_by=sort_by,
61 59
         sort_order=sort_order,
62 60
     )
63
-    import math
64 61
 
65 62
     items = [
66 63
         DocumentListItem.model_validate(d).model_dump(by_alias=True) for d in docs
@@ -72,7 +69,7 @@ async def list_documents(
72 69
         total_pages=math.ceil(total / page_size) if page_size else 1,
73 70
     ).model_dump(by_alias=True)
74 71
 
75
-    return _ok({"documents": items, "pagination": pagination})
72
+    return ok({"documents": items, "pagination": pagination})
76 73
 
77 74
 
78 75
 # ------------------------------------------------------------------ #
@@ -85,7 +82,7 @@ async def get_document(
85 82
 ) -> dict:
86 83
     svc = DocumentService(db)
87 84
     doc = await svc.get_document(document_id)
88
-    return _ok(DocumentResponse.model_validate(doc).model_dump(by_alias=True))
85
+    return ok(DocumentResponse.model_validate(doc).model_dump(by_alias=True))
89 86
 
90 87
 
91 88
 # ------------------------------------------------------------------ #
@@ -99,7 +96,7 @@ async def update_document(
99 96
 ) -> dict:
100 97
     svc = DocumentService(db)
101 98
     doc = await svc.update_document(document_id, body)
102
-    return _ok(
99
+    return ok(
103 100
         {
104 101
             "documentId": doc.id,
105 102
             "updatedAt": int(doc.updated_at.timestamp() * 1000),

+ 7 - 14
app/api/v1/export.py

@@ -1,16 +1,16 @@
1 1
 from fastapi import APIRouter
2 2
 
3
-from app.core.exceptions import DocumentNotFoundError, ExportError
3
+from app.api.v1 import ok
4
+from app.core.exceptions import ExportError
4 5
 from app.schemas.export import ExportDocRequest, ExportDocResponse
5 6
 from app.services.export_service import export_doc
7
+from app.core.database import AsyncSessionLocal
8
+from app.schemas.document import UpdateDocumentRequest
9
+from app.services.document_service import DocumentService
6 10
 
7 11
 router = APIRouter(tags=["Export"])
8 12
 
9 13
 
10
-def _ok(data: dict) -> dict:
11
-    return {"code": 0, "message": "Success", "data": data}
12
-
13
-
14 14
 # ------------------------------------------------------------------ #
15 15
 # POST /export/doc  — 导出 .doc,写入记录,返回永久下载链接
16 16
 # ------------------------------------------------------------------ #
@@ -20,18 +20,12 @@ async def export_document(body: ExportDocRequest) -> dict:
20 20
     # 若传入 documentId,可选同步更新草稿(失败不阻塞导出)
21 21
     if body.document_id:
22 22
         try:
23
-            from app.core.database import AsyncSessionLocal
24
-            from app.schemas.document import UpdateDocumentRequest
25
-            from app.services.document_service import DocumentService
26
-
27 23
             async with AsyncSessionLocal() as db:
28 24
                 svc = DocumentService(db)
29 25
                 await svc.update_document(
30 26
                     body.document_id,
31 27
                     UpdateDocumentRequest(content=body.content),
32 28
                 )
33
-        except DocumentNotFoundError:
34
-            pass
35 29
         except Exception:
36 30
             pass
37 31
 
@@ -43,11 +37,10 @@ async def export_document(body: ExportDocRequest) -> dict:
43 37
         document_id=body.document_id,
44 38
     )
45 39
 
46
-    resp = ExportDocResponse(
40
+    return ok(ExportDocResponse(
47 41
         record_id=result["record_id"],
48 42
         download_url=result["download_url"],
49 43
         file_name=result["file_name"],
50 44
         style_id=result["style_id"],
51 45
         warning=result["warning"],
52
-    )
53
-    return _ok(resp.model_dump(by_alias=True))
46
+    ).model_dump(by_alias=True))

+ 6 - 19
app/api/v1/export_records.py

@@ -1,22 +1,20 @@
1 1
 """export_records.py — 导出记录管理路由及管理端存储查询。"""
2 2
 
3 3
 import math
4
-
4
+from pathlib import Path
5 5
 from fastapi import APIRouter
6 6
 from fastapi.responses import FileResponse
7 7
 
8
+from app.api.v1 import ok
8 9
 from app.core.database import AsyncSessionLocal
9 10
 from app.core.exceptions import RecordNotFoundError
11
+from app.schemas.export import ExportRecordSchema
10 12
 from app.services.export_record_service import ExportRecordService
11 13
 from app.services.storage_monitor import get_storage_info
12 14
 
13 15
 router = APIRouter(tags=["Export Records"])
14 16
 
15 17
 
16
-def _ok(data: dict) -> dict:
17
-    return {"code": 0, "data": data}
18
-
19
-
20 18
 # ------------------------------------------------------------------ #
21 19
 # GET /export/records  — 分页查询导出记录
22 20
 # ------------------------------------------------------------------ #
@@ -41,19 +39,10 @@ async def list_export_records(
41 39
         )
42 40
 
43 41
     total_pages = math.ceil(total / page_size) if total > 0 else 1
44
-    return _ok(
42
+    return ok(
45 43
         {
46 44
             "records": [
47
-                {
48
-                    "recordId": r.id,
49
-                    "userId": r.user_id,
50
-                    "fileName": r.file_name,
51
-                    "fileSize": r.file_size,
52
-                    "downloadUrl": r.download_url,
53
-                    "documentId": r.document_id,
54
-                    "styleId": r.style_id,
55
-                    "createdAt": int(r.created_at.timestamp() * 1000),
56
-                }
45
+                ExportRecordSchema.from_record(r).model_dump(by_alias=True)
57 46
                 for r in records
58 47
             ],
59 48
             "pagination": {
@@ -72,8 +61,6 @@ async def list_export_records(
72 61
 
73 62
 @router.get("/export/records/{recordId}/download", summary="重新下载导出文件")
74 63
 async def download_export_record(recordId: str, userId: str) -> FileResponse:
75
-    from pathlib import Path
76
-
77 64
     async with AsyncSessionLocal() as db:
78 65
         svc = ExportRecordService(db)
79 66
         record = await svc.get_record(recordId, userId)
@@ -109,7 +96,7 @@ async def delete_export_record(recordId: str, userId: str) -> dict:
109 96
 @router.get("/admin/storage", summary="管理端:存储使用情况")
110 97
 async def admin_storage() -> dict:
111 98
     info = get_storage_info()
112
-    return _ok(
99
+    return ok(
113 100
         {
114 101
             "diskTotalBytes": info["disk_total_bytes"],
115 102
             "diskUsedBytes": info["disk_used_bytes"],

binární
app/core/__pycache__/dependencies.cpython-311.pyc


binární
app/core/__pycache__/exceptions.cpython-311.pyc


+ 1 - 7
app/core/dependencies.py

@@ -7,10 +7,4 @@ from app.core.database import AsyncSessionLocal
7 7
 
8 8
 async def get_db() -> AsyncGenerator[AsyncSession, None]:
9 9
     async with AsyncSessionLocal() as session:
10
-        try:
11
-            yield session
12
-        except Exception:
13
-            await session.rollback()
14
-            raise
15
-        finally:
16
-            await session.close()
10
+        yield session

+ 19 - 35
app/core/exceptions.py

@@ -24,39 +24,23 @@ class RecordNotFoundError(Exception):
24 24
         super().__init__(f"Export record '{record_id}' not found")
25 25
 
26 26
 
27
+# (异常类, HTTP 状态码, 响应 code 字段)
28
+_HANDLERS: list[tuple[type[Exception], int, int]] = [
29
+    (DocumentNotFoundError, 404,  404),
30
+    (ContentTooLargeError,  413,  413),
31
+    (ExportError,           500,  500),
32
+    (RecordNotFoundError,   404, 4041),
33
+]
34
+
35
+
27 36
 def register_exception_handlers(app: FastAPI) -> None:
28
-    @app.exception_handler(DocumentNotFoundError)
29
-    async def document_not_found_handler(
30
-        request: Request, exc: DocumentNotFoundError
31
-    ) -> JSONResponse:
32
-        return JSONResponse(
33
-            status_code=404,
34
-            content={"code": 404, "message": str(exc), "data": None},
35
-        )
36
-
37
-    @app.exception_handler(ContentTooLargeError)
38
-    async def content_too_large_handler(
39
-        request: Request, exc: ContentTooLargeError
40
-    ) -> JSONResponse:
41
-        return JSONResponse(
42
-            status_code=413,
43
-            content={"code": 413, "message": str(exc), "data": None},
44
-        )
45
-
46
-    @app.exception_handler(ExportError)
47
-    async def export_error_handler(
48
-        request: Request, exc: ExportError
49
-    ) -> JSONResponse:
50
-        return JSONResponse(
51
-            status_code=500,
52
-            content={"code": 500, "message": str(exc), "data": None},
53
-        )
54
-
55
-    @app.exception_handler(RecordNotFoundError)
56
-    async def record_not_found_handler(
57
-        request: Request, exc: RecordNotFoundError
58
-    ) -> JSONResponse:
59
-        return JSONResponse(
60
-            status_code=404,
61
-            content={"code": 4041, "message": str(exc), "data": None},
62
-        )
37
+    def _make_handler(http_status: int, code: int):
38
+        async def handler(request: Request, exc: Exception) -> JSONResponse:
39
+            return JSONResponse(
40
+                status_code=http_status,
41
+                content={"code": code, "message": str(exc), "data": None},
42
+            )
43
+        return handler
44
+
45
+    for exc_class, http_status, code in _HANDLERS:
46
+        app.add_exception_handler(exc_class, _make_handler(http_status, code))

binární
app/models/__pycache__/__init__.cpython-311.pyc


binární
app/schemas/__pycache__/export.cpython-311.pyc


+ 19 - 1
app/schemas/export.py

@@ -1,7 +1,12 @@
1
-from typing import Literal, Optional
1
+from __future__ import annotations
2
+
3
+from typing import TYPE_CHECKING, Literal, Optional
2 4
 
3 5
 from pydantic import BaseModel, Field
4 6
 
7
+if TYPE_CHECKING:
8
+    from app.models.export_record import ExportRecord
9
+
5 10
 
6 11
 # ------------------------------------------------------------------ #
7 12
 # 导出请求 / 响应
@@ -44,6 +49,19 @@ class ExportRecordSchema(BaseModel):
44 49
 
45 50
     model_config = {"populate_by_name": True}
46 51
 
52
+    @classmethod
53
+    def from_record(cls, r: "ExportRecord") -> "ExportRecordSchema":
54
+        return cls(
55
+            record_id=r.id,
56
+            user_id=r.user_id,
57
+            file_name=r.file_name,
58
+            file_size=r.file_size,
59
+            download_url=r.download_url,
60
+            document_id=r.document_id,
61
+            style_id=r.style_id,
62
+            created_at=int(r.created_at.timestamp() * 1000),
63
+        )
64
+
47 65
 
48 66
 class PaginationSchema(BaseModel):
49 67
     page: int

binární
app/services/__pycache__/document_service.cpython-311.pyc


binární
app/services/__pycache__/export_service.cpython-311.pyc


binární
app/services/__pycache__/storage_monitor.cpython-311.pyc


+ 16 - 24
app/services/document_service.py

@@ -1,7 +1,7 @@
1 1
 import re
2 2
 from datetime import datetime, timezone
3 3
 
4
-from sqlalchemy import select
4
+from sqlalchemy import func, select
5 5
 from sqlalchemy.ext.asyncio import AsyncSession
6 6
 
7 7
 from app.core.exceptions import ContentTooLargeError, DocumentNotFoundError
@@ -23,8 +23,9 @@ class DocumentService:
23 23
         data: CreateDocumentRequest,
24 24
         user_id: str | None = None,
25 25
     ) -> Document:
26
-        if len(data.content.encode("utf-8")) > CONTENT_MAX_BYTES:
27
-            raise ContentTooLargeError(len(data.content.encode("utf-8")))
26
+        size = len(data.content.encode("utf-8"))
27
+        if size > CONTENT_MAX_BYTES:
28
+            raise ContentTooLargeError(size)
28 29
 
29 30
         doc = Document(
30 31
             title=data.title,
@@ -71,11 +72,9 @@ class DocumentService:
71 72
         else:
72 73
             query = query.order_by(sort_col.desc())
73 74
 
74
-        # 总数
75
-        count_result = await self.db.execute(
76
-            query.with_only_columns(Document.id)
77
-        )
78
-        total = len(count_result.all())
75
+        # 总数:SELECT COUNT(*) 而非拉全部 id 再 len()
76
+        count_q = select(func.count()).select_from(query.subquery())
77
+        total: int = (await self.db.execute(count_q)).scalar_one()
79 78
 
80 79
         # 分页
81 80
         offset = (page - 1) * page_size
@@ -95,8 +94,9 @@ class DocumentService:
95 94
             doc.title = data.title
96 95
 
97 96
         if data.content is not None:
98
-            if len(data.content.encode("utf-8")) > CONTENT_MAX_BYTES:
99
-                raise ContentTooLargeError(len(data.content.encode("utf-8")))
97
+            size = len(data.content.encode("utf-8"))
98
+            if size > CONTENT_MAX_BYTES:
99
+                raise ContentTooLargeError(size)
100 100
             doc.content = data.content
101 101
 
102 102
         if data.blocks is not None:
@@ -144,12 +144,6 @@ class DocumentService:
144 144
             heading_info.append((line_idx, level, idx))
145 145
             level_counter[level] = idx + 1
146 146
 
147
-        # 构建查找字典 (level, index) → line_idx
148
-        heading_map: dict[tuple[int, int], int] = {
149
-            (level, index): line_idx
150
-            for line_idx, level, index in heading_info
151
-        }
152
-
153 147
         # 将 lines 拆成块
154 148
         # 块边界 = 各标题行的 line_idx
155 149
         split_points = sorted({line_idx for line_idx, _, _ in heading_info})
@@ -157,22 +151,20 @@ class DocumentService:
157 151
 
158 152
         # 前置正文(首个标题之前的内容)
159 153
         pre_content_end = split_points[0] if split_points else len(lines)
160
-        chunks: list[str] = []
161
-        chunks.append("\n".join(lines[:pre_content_end]))
154
+        chunks: list[str] = ["\n".join(lines[:pre_content_end])]
162 155
 
163
-        # 各标题块
164
-        block_keys: list[tuple[int, int] | None] = [None]  # 对应 pre_content
156
+        # 各标题块,同时构建 (level, index) → chunk_idx 映射,O(1) 定位
157
+        key_to_chunk: dict[tuple[int, int], int] = {}
165 158
         for i, sp in enumerate(split_points[:-1]):
166 159
             end = split_points[i + 1]
167 160
             chunks.append("\n".join(lines[sp:end]))
168 161
             _, level, index = heading_info[i]
169
-            block_keys.append((level, index))
162
+            key_to_chunk[(level, index)] = len(chunks) - 1
170 163
 
171 164
         # 执行替换
172 165
         for block_update in blocks:
173 166
             key = (block_update.level, block_update.index)
174
-            if key in heading_map:
175
-                chunk_idx = block_keys.index(key)
176
-                chunks[chunk_idx] = block_update.content
167
+            if key in key_to_chunk:
168
+                chunks[key_to_chunk[key]] = block_update.content
177 169
 
178 170
         return "\n".join(chunks).strip() + "\n"

+ 8 - 23
app/services/export_service.py

@@ -6,6 +6,7 @@ import secrets
6 6
 import unicodedata
7 7
 from pathlib import Path
8 8
 from typing import Optional
9
+from datetime import date
9 10
 
10 11
 import mistune
11 12
 from docx import Document
@@ -16,6 +17,9 @@ from lxml import etree
16 17
 
17 18
 from app.config import settings
18 19
 from app.core.exceptions import ExportError
20
+from app.core.database import AsyncSessionLocal
21
+from app.services.export_record_service import ExportRecordService
22
+from app.services.storage_monitor import check_quota
19 23
 
20 24
 
21 25
 # ------------------------------------------------------------------ #
@@ -58,8 +62,7 @@ def build_style_map(style_data: dict) -> dict[str, dict]:
58 62
 def dict_to_element(d: dict) -> etree._Element:
59 63
     """将 element_to_dict() 产生的字典还原为 lxml Element(styles.py 的逆操作)。"""
60 64
     tag = d["@tag"]
61
-    attrib = {k: v for k, v in d.get("@attrib", {}).items()}
62
-    elem = etree.Element(tag, attrib=attrib)
65
+    elem = etree.Element(tag, attrib=dict(d.get("@attrib", {})))
63 66
 
64 67
     if d.get("#text"):
65 68
         elem.text = d["#text"]
@@ -126,14 +129,7 @@ class DocxRenderer(mistune.BaseRenderer):
126 129
     """将 mistune AST token 流渲染到 python-docx Document 对象。"""
127 130
 
128 131
     # Word 内置标题样式的英文名(用于查找 style_map 时的候选 key)
129
-    _HEADING_ALIASES = {
130
-        1: ["Heading 1", "heading 1"],
131
-        2: ["Heading 2", "heading 2"],
132
-        3: ["Heading 3", "heading 3"],
133
-        4: ["Heading 4", "heading 4"],
134
-        5: ["Heading 5", "heading 5"],
135
-        6: ["Heading 6", "heading 6"],
136
-    }
132
+    _HEADING_ALIASES = {i: [f"Heading {i}", f"heading {i}"] for i in range(1, 7)}
137 133
 
138 134
     def __init__(self, style_map: dict, style_data: dict) -> None:
139 135
         """初始化渲染器,注入完整样式定义并缓存常用样式 ID。"""
@@ -415,7 +411,6 @@ async def export_doc(
415 411
     document_id: Optional[str] = None,
416 412
 ) -> dict:
417 413
     """导出入口:加载样式、生成 .docx,按用户/日期分区写入,写入 export_records 记录,返回 { record_id, download_url, file_name, style_id, warning }。"""
418
-    from datetime import date
419 414
 
420 415
     # 1. 加载样式文件,构建映射
421 416
     style_data = load_style_file(style_id)
@@ -445,16 +440,7 @@ async def export_doc(
445 440
 
446 441
     file_size = file_path.stat().st_size
447 442
 
448
-    # 4. 生成永久下载链接(无过期时间)
449
-    download_url = (
450
-        f"{settings.base_url.rstrip('/')}/api/v1/export/records"
451
-        # 占位,record_id 写入 DB 后拼接
452
-    )
453
-
454
-    # 5. 写入 export_records 数据库记录
455
-    from app.core.database import AsyncSessionLocal
456
-    from app.services.export_record_service import ExportRecordService
457
-
443
+    # 4. 写入 export_records 数据库记录
458 444
     async with AsyncSessionLocal() as db:
459 445
         svc = ExportRecordService(db)
460 446
         record = await svc.create_record(
@@ -462,7 +448,7 @@ async def export_doc(
462 448
             file_name=final_name,
463 449
             file_path=str(file_path),
464 450
             file_size=file_size,
465
-            download_url="",          # 先占位,下面用 record_id 补全
451
+            download_url="",
466 452
             document_id=document_id,
467 453
             style_id=actual_style_id,
468 454
         )
@@ -476,7 +462,6 @@ async def export_doc(
476 462
         await db.commit()
477 463
 
478 464
     # 6. 导出后检查磁盘配额
479
-    from app.services.storage_monitor import check_quota
480 465
     warning = check_quota(user_id)
481 466
 
482 467
     return {

+ 22 - 31
app/services/storage_monitor.py

@@ -72,31 +72,36 @@ def get_storage_info() -> dict:
72 72
 # 配额检查(导出后调用 / 定时任务共用)
73 73
 # ------------------------------------------------------------------ #
74 74
 
75
+def _log_quota_warnings(info: dict, prefix: str) -> None:
76
+    """将超限情况写入日志,供 check_quota 和定时任务共用。"""
77
+    if info["quota_exceeded"]:
78
+        logger.warning(
79
+            "[%s] tmp/ 总占用 %d bytes 超过配额 %d bytes",
80
+            prefix,
81
+            info["tmp_total_bytes"],
82
+            info["quota_bytes"],
83
+        )
84
+    for u in info["users"]:
85
+        if u["quota_exceeded"]:
86
+            logger.warning(
87
+                "[%s] 用户 %s 占用 %d bytes 超过均分配额 %d bytes",
88
+                prefix,
89
+                u["user_id"],
90
+                u["used_bytes"],
91
+                info["per_user_quota_bytes"],
92
+            )
93
+
94
+
75 95
 def check_quota(user_id: str) -> str | None:
76 96
     """
77 97
     检查存储配额,返回用户侧 warning 文本;无超限时返回 None。
78 98
     同时将管理员级别超限情况写入日志。
79 99
     """
80 100
     info = get_storage_info()
101
+    _log_quota_warnings(info, prefix="存储告警")
81 102
 
82
-    # 全局超限 → 记录管理员日志
83
-    if info["quota_exceeded"]:
84
-        logger.warning(
85
-            "[存储告警] tmp/ 总占用 %d bytes 超过配额 %d bytes(磁盘 %.0f%%)",
86
-            info["tmp_total_bytes"],
87
-            info["quota_bytes"],
88
-            settings.disk_quota_ratio * 100,
89
-        )
90
-
91
-    # 检查当前用户是否超个人配额
92 103
     user_entry = next((u for u in info["users"] if u["user_id"] == user_id), None)
93 104
     if user_entry and user_entry["quota_exceeded"]:
94
-        logger.warning(
95
-            "[存储告警] 用户 %s 占用 %d bytes 超过均分配额 %d bytes",
96
-            user_id,
97
-            user_entry["used_bytes"],
98
-            info["per_user_quota_bytes"],
99
-        )
100 105
         return "您的存储空间已超出限额,请删除旧文件释放空间"
101 106
 
102 107
     return None
@@ -111,21 +116,7 @@ async def _periodic_check(interval_seconds: int = 1800) -> None:
111 116
     while True:
112 117
         await asyncio.sleep(interval_seconds)
113 118
         try:
114
-            info = get_storage_info()
115
-            if info["quota_exceeded"]:
116
-                logger.warning(
117
-                    "[定时检查] tmp/ 总占用 %d bytes 超过配额 %d bytes",
118
-                    info["tmp_total_bytes"],
119
-                    info["quota_bytes"],
120
-                )
121
-            for u in info["users"]:
122
-                if u["quota_exceeded"]:
123
-                    logger.warning(
124
-                        "[定时检查] 用户 %s 占用 %d bytes 超过均分配额 %d bytes",
125
-                        u["user_id"],
126
-                        u["used_bytes"],
127
-                        info["per_user_quota_bytes"],
128
-                    )
119
+            _log_quota_warnings(get_storage_info(), prefix="定时检查")
129 120
         except Exception:
130 121
             logger.exception("[定时检查] 磁盘检查异常")
131 122