bid-master-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. app/__init__.py +1 -0
  2. app/api/__init__.py +1 -0
  3. app/api/api_keys.py +60 -0
  4. app/api/auth.py +258 -0
  5. app/api/cli_auth.py +165 -0
  6. app/api/database.py +286 -0
  7. app/api/extract.py +158 -0
  8. app/api/files.py +163 -0
  9. app/api/health.py +62 -0
  10. app/api/logs.py +26 -0
  11. app/api/settings.py +101 -0
  12. app/api/simulate.py +195 -0
  13. app/api/statistics.py +1214 -0
  14. app/cli.py +894 -0
  15. app/config.py +93 -0
  16. app/dependencies.py +12 -0
  17. app/infrastructure/__init__.py +1 -0
  18. app/infrastructure/database.py +126 -0
  19. app/infrastructure/db_schema.py +245 -0
  20. app/infrastructure/email_service.py +92 -0
  21. app/infrastructure/llm/__init__.py +1 -0
  22. app/infrastructure/llm/lite_llm.py +463 -0
  23. app/infrastructure/log_collector.py +64 -0
  24. app/infrastructure/mock_storage.py +563 -0
  25. app/infrastructure/pg_storage.py +656 -0
  26. app/infrastructure/storage.py +117 -0
  27. app/limiter.py +7 -0
  28. app/main.py +141 -0
  29. app/models/__init__.py +1 -0
  30. app/models/schemas.py +204 -0
  31. app/services/__init__.py +1 -0
  32. app/services/encryption_service.py +88 -0
  33. app/services/extract_service.py +817 -0
  34. app/services/file_service.py +112 -0
  35. app/services/llm_service.py +65 -0
  36. app/services/ocr_service.py +183 -0
  37. app/services/prompt_builder.py +257 -0
  38. app/services/simulate_service.py +625 -0
  39. app/services/statistics_service.py +123 -0
  40. app/utils/__init__.py +1 -0
  41. app/utils/auth_dep.py +42 -0
  42. app/utils/crypto.py +63 -0
  43. app/utils/exceptions.py +53 -0
  44. bid_master_cli-1.0.0.dist-info/METADATA +30 -0
  45. bid_master_cli-1.0.0.dist-info/RECORD +47 -0
  46. bid_master_cli-1.0.0.dist-info/WHEEL +4 -0
  47. bid_master_cli-1.0.0.dist-info/entry_points.txt +2 -0
app/api/database.py ADDED
@@ -0,0 +1,286 @@
1
+ """
2
+ 文件管理 API 路由:统一的 CRUD 接口,覆盖文件/模拟/开标/提取四大模块。
3
+ 当前使用内存 mock 数据,数据库连接后将切换为 Drizzle ORM。
4
+ 所有端点强制认证,按 user_id 隔离数据。
5
+ """
6
+ import io
7
+ import json
8
+ import zipfile
9
+ from fastapi import APIRouter, Depends, HTTPException, Query
10
+ from fastapi.responses import StreamingResponse
11
+ from typing import Optional
12
+ from urllib.parse import quote
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from app.infrastructure.pg_storage import (
17
+ get_stats, list_files, get_file, delete_file,
18
+ list_simulates, get_simulate, delete_simulate,
19
+ list_openings, get_opening, delete_opening,
20
+ list_extracts, get_extract, delete_extract,
21
+ )
22
+ from app.services.file_service import get_file_service
23
+ from app.utils.auth_dep import get_current_user
24
+
25
+ router = APIRouter(prefix="/data", tags=["data"])
26
+
27
+
28
+ class BatchDownloadRequest(BaseModel):
29
+ file_ids: list[str]
30
+
31
+
32
+ # --- 统计概览 ---
33
+
34
+
35
+ @router.get("/stats")
36
+ async def api_get_stats(current_user: dict = Depends(get_current_user)):
37
+ """获取各模块数据总数。"""
38
+ return await get_stats(user_id=current_user["id"])
39
+
40
+
41
+ # --- 文件管理 ---
42
+
43
+
44
+ @router.get("/files")
45
+ async def api_list_files(
46
+ page: int = Query(1, ge=1),
47
+ page_size: int = Query(20, ge=1, le=100),
48
+ file_type: Optional[str] = None,
49
+ current_user: dict = Depends(get_current_user),
50
+ ):
51
+ """分页列出文件,可按类型筛选。"""
52
+ return await list_files(page, page_size, file_type, user_id=current_user["id"])
53
+
54
+
55
+ @router.get("/files/{file_id}")
56
+ async def api_get_file(file_id: str, current_user: dict = Depends(get_current_user)):
57
+ """获取单个文件详情。"""
58
+ record = await get_file(file_id, user_id=current_user["id"])
59
+ if not record:
60
+ raise HTTPException(status_code=404, detail="File not found")
61
+ return {"file": record}
62
+
63
+
64
+ @router.delete("/files/{file_id}")
65
+ async def api_delete_file(file_id: str, current_user: dict = Depends(get_current_user)):
66
+ """删除文件。"""
67
+ deleted = await delete_file(file_id, user_id=current_user["id"])
68
+ if not deleted:
69
+ raise HTTPException(status_code=404, detail="File not found")
70
+ return {"success": True}
71
+
72
+
73
+ @router.get("/files/{file_id}/download")
74
+ async def api_download_file(file_id: str, current_user: dict = Depends(get_current_user)):
75
+ """下载原始文件(加密存储,解密后返回)。"""
76
+ record = await get_file(file_id, user_id=current_user["id"])
77
+ if not record:
78
+ raise HTTPException(status_code=404, detail="File not found")
79
+ try:
80
+ file_service = get_file_service()
81
+ content = await file_service.download(file_id, current_user["id"])
82
+ filename = record.get("original_name", file_id)
83
+ encoded_filename = quote(filename)
84
+ return StreamingResponse(
85
+ iter([content]),
86
+ media_type="application/octet-stream",
87
+ headers={
88
+ "Content-Disposition": f"attachment; filename=\"{encoded_filename}\"; filename*=UTF-8''{encoded_filename}"
89
+ }
90
+ )
91
+ except FileNotFoundError:
92
+ raise HTTPException(status_code=404, detail="文件不存在(演示数据无法下载,请上传真实文件)")
93
+ except Exception as e:
94
+ raise HTTPException(status_code=500, detail=str(e))
95
+
96
+
97
+ @router.get("/files/{file_id}/preview")
98
+ async def api_preview_file(file_id: str, current_user: dict = Depends(get_current_user)):
99
+ """预览文件(inline 返回,浏览器直接渲染)。"""
100
+ record = await get_file(file_id, user_id=current_user["id"])
101
+ if not record:
102
+ raise HTTPException(status_code=404, detail="File not found")
103
+ try:
104
+ file_service = get_file_service()
105
+ content = await file_service.download(file_id, current_user["id"])
106
+ mime_map = {
107
+ "pdf": "application/pdf",
108
+ "word": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
109
+ "excel": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
110
+ "csv": "text/csv",
111
+ "markdown": "text/markdown",
112
+ }
113
+ mime_type = mime_map.get(record.get("type", ""), "application/octet-stream")
114
+ filename = record.get("original_name", file_id)
115
+ encoded_filename = quote(filename)
116
+ return StreamingResponse(
117
+ iter([content]),
118
+ media_type=mime_type,
119
+ headers={
120
+ "Content-Disposition": f"inline; filename=\"{encoded_filename}\"; filename*=UTF-8''{encoded_filename}"
121
+ }
122
+ )
123
+ except FileNotFoundError:
124
+ raise HTTPException(status_code=404, detail="文件不存在(演示数据无法预览,请上传真实文件)")
125
+ except Exception as e:
126
+ raise HTTPException(status_code=500, detail=str(e))
127
+
128
+
129
+ @router.post("/files/batch-download")
130
+ async def api_batch_download_files(body: BatchDownloadRequest, current_user: dict = Depends(get_current_user)):
131
+ """批量下载文件,打包为 ZIP 返回。"""
132
+ if not body.file_ids:
133
+ raise HTTPException(status_code=400, detail="file_ids 不能为空")
134
+
135
+ zip_buffer = io.BytesIO()
136
+ file_service = get_file_service()
137
+
138
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
139
+ for file_id in body.file_ids:
140
+ record = await get_file(file_id, user_id=current_user["id"])
141
+ if not record:
142
+ continue
143
+ try:
144
+ content = await file_service.download(file_id, current_user["id"])
145
+ filename = record.get("original_name", file_id)
146
+ # 去重:同名文件加 ID 后缀
147
+ info = zf.NameToInfo.get(filename)
148
+ if info is not None:
149
+ name, ext = filename.rsplit(".", 1) if "." in filename else (filename, "")
150
+ filename = f"{name}_{file_id[:8]}.{ext}" if ext else f"{name}_{file_id[:8]}"
151
+ zf.writestr(filename, content)
152
+ except FileNotFoundError:
153
+ continue
154
+ except Exception:
155
+ continue
156
+
157
+ zip_buffer.seek(0)
158
+ return StreamingResponse(
159
+ iter([zip_buffer.getvalue()]),
160
+ media_type="application/zip",
161
+ headers={
162
+ "Content-Disposition": "attachment; filename=\"batch_download.zip\""
163
+ }
164
+ )
165
+
166
+
167
+ # --- 模拟任务 ---
168
+
169
+
170
+ @router.get("/simulates")
171
+ async def api_list_simulates(
172
+ page: int = Query(1, ge=1),
173
+ page_size: int = Query(20, ge=1, le=100),
174
+ status: Optional[str] = None,
175
+ current_user: dict = Depends(get_current_user),
176
+ ):
177
+ """分页列出模拟任务,可按状态筛选。"""
178
+ return await list_simulates(page, page_size, status, user_id=current_user["id"])
179
+
180
+
181
+ @router.get("/simulates/{task_id}")
182
+ async def api_get_simulate(task_id: str, current_user: dict = Depends(get_current_user)):
183
+ """获取模拟任务详情。"""
184
+ record = await get_simulate(task_id, user_id=current_user["id"])
185
+ if not record:
186
+ raise HTTPException(status_code=404, detail="Task not found")
187
+ return {"task": record}
188
+
189
+
190
+ @router.delete("/simulates/{task_id}")
191
+ async def api_delete_simulate(task_id: str, current_user: dict = Depends(get_current_user)):
192
+ """删除模拟任务。"""
193
+ deleted = await delete_simulate(task_id, user_id=current_user["id"])
194
+ if not deleted:
195
+ raise HTTPException(status_code=404, detail="Task not found")
196
+ return {"success": True}
197
+
198
+
199
+ # --- 开标结果 ---
200
+
201
+
202
+ @router.get("/openings")
203
+ async def api_list_openings(
204
+ page: int = Query(1, ge=1),
205
+ page_size: int = Query(20, ge=1, le=100),
206
+ current_user: dict = Depends(get_current_user),
207
+ ):
208
+ """分页列出开标分析结果。"""
209
+ return await list_openings(page, page_size, user_id=current_user["id"])
210
+
211
+
212
+ @router.get("/openings/{task_id}")
213
+ async def api_get_opening(task_id: str, current_user: dict = Depends(get_current_user)):
214
+ """获取开标结果详情。"""
215
+ record = await get_opening(task_id, user_id=current_user["id"])
216
+ if not record:
217
+ raise HTTPException(status_code=404, detail="Result not found")
218
+ return record
219
+
220
+
221
+ @router.delete("/openings/{task_id}")
222
+ async def api_delete_opening(task_id: str, current_user: dict = Depends(get_current_user)):
223
+ """删除开标结果。"""
224
+ deleted = await delete_opening(task_id, user_id=current_user["id"])
225
+ if not deleted:
226
+ raise HTTPException(status_code=404, detail="Result not found")
227
+ return {"success": True}
228
+
229
+
230
+ # --- 提取结果 ---
231
+
232
+
233
+ @router.get("/extracts")
234
+ async def api_list_extracts(
235
+ page: int = Query(1, ge=1),
236
+ page_size: int = Query(20, ge=1, le=100),
237
+ current_user: dict = Depends(get_current_user),
238
+ ):
239
+ """分页列出提取结果。"""
240
+ return await list_extracts(page, page_size, user_id=current_user["id"])
241
+
242
+
243
+ @router.get("/extracts/{result_id}")
244
+ async def api_get_extract(result_id: str, current_user: dict = Depends(get_current_user)):
245
+ """获取提取结果详情。"""
246
+ record = await get_extract(result_id, user_id=current_user["id"])
247
+ if not record:
248
+ raise HTTPException(status_code=404, detail="Result not found")
249
+ return record
250
+
251
+
252
+ @router.get("/extracts/{result_id}/export-json")
253
+ async def api_export_extract_json(result_id: str, current_user: dict = Depends(get_current_user)):
254
+ record = await get_extract(result_id, user_id=current_user["id"])
255
+ if not record:
256
+ raise HTTPException(status_code=404, detail="Result not found")
257
+
258
+ payload = {
259
+ "id": record.get("id"),
260
+ "file_id": record.get("file_id"),
261
+ "file_name": record.get("file_name") or record.get("name"),
262
+ "template_type": record.get("template_type"),
263
+ "mode": record.get("mode"),
264
+ "status": record.get("status"),
265
+ "created_at": record.get("created_at"),
266
+ "elements": record.get("elements") or [],
267
+ "markdown_content": record.get("content") or "",
268
+ }
269
+ content = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
270
+ filename = quote(f"extract_{result_id}.json")
271
+ return StreamingResponse(
272
+ iter([content]),
273
+ media_type="application/json",
274
+ headers={
275
+ "Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{filename}"
276
+ },
277
+ )
278
+
279
+
280
+ @router.delete("/extracts/{result_id}")
281
+ async def api_delete_extract(result_id: str, current_user: dict = Depends(get_current_user)):
282
+ """删除提取结果。"""
283
+ deleted = await delete_extract(result_id, user_id=current_user["id"])
284
+ if not deleted:
285
+ raise HTTPException(status_code=404, detail="Result not found")
286
+ return {"success": True}
app/api/extract.py ADDED
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+ """
3
+ Document extraction API routes with SSE support.
4
+ 所有端点强制认证,提取结果归属当前用户。
5
+ """
6
+ import json
7
+ from fastapi import APIRouter, Depends, HTTPException, Request
8
+ from sse_starlette.sse import EventSourceResponse
9
+
10
+ from app.services.extract_service import ExtractService
11
+ from app.models.schemas import ExtractRequest, BatchExtractRequest, ThresholdRequest
12
+ from app.limiter import limiter
13
+ from app.utils.auth_dep import get_current_user
14
+
15
+ router = APIRouter(prefix="/extract", tags=["extract"])
16
+
17
+
18
+ async def extract_elements_generator(
19
+ document_id: str,
20
+ provider: str = "deepseek",
21
+ model: str = None,
22
+ template_type: str = "standard",
23
+ elements: list[str] = None,
24
+ mode: str = "single",
25
+ params: dict = None,
26
+ user_id: str = None,
27
+ ):
28
+ """
29
+ Generate SSE events for element extraction.
30
+ Yields properly JSON-encoded data for SSE.
31
+ """
32
+ extract_service = ExtractService()
33
+
34
+ async for event in extract_service.extract_elements_stream(
35
+ document_id, provider, model, template_type, elements, mode, params, user_id=user_id
36
+ ):
37
+ yield {
38
+ "event": event.get("type", "message"),
39
+ "data": json.dumps(event, ensure_ascii=False),
40
+ }
41
+
42
+
43
+ @router.post("/element")
44
+ @limiter.limit("20/minute")
45
+ async def extract_element(request: Request, current_user: dict = Depends(get_current_user)):
46
+ """单文件要素提取(SSE 流式)。限速 20次/分钟。"""
47
+ body = await request.json()
48
+ req = ExtractRequest(**body)
49
+ return EventSourceResponse(
50
+ extract_elements_generator(
51
+ req.fileId,
52
+ req.provider or "deepseek",
53
+ req.model,
54
+ req.template_type or "standard",
55
+ req.elements,
56
+ req.mode or "single",
57
+ user_id=current_user["id"],
58
+ )
59
+ )
60
+
61
+
62
+ # =============================================================================
63
+ # Batch & Threshold endpoints
64
+ # =============================================================================
65
+
66
+
67
+ async def extract_batch_generator(
68
+ file_ids: list[str],
69
+ provider: str = "deepseek",
70
+ model: str = None,
71
+ elements: list[str] = None,
72
+ user_id: str = None,
73
+ ):
74
+ extract_service = ExtractService()
75
+ async for event in extract_service.extract_batch_stream(file_ids, provider, model, elements, user_id=user_id):
76
+ yield {
77
+ "event": event.get("type", "message"),
78
+ "data": json.dumps(event, ensure_ascii=False),
79
+ }
80
+
81
+
82
+ @router.post("/element/batch")
83
+ @limiter.limit("10/minute")
84
+ async def extract_batch(request: Request, current_user: dict = Depends(get_current_user)):
85
+ """批量对比:多文件并行提取 + 横向对比分析。限速 10次/分钟。"""
86
+ body = await request.json()
87
+ req = BatchExtractRequest(**body)
88
+ if len(req.fileIds) < 2:
89
+ raise HTTPException(status_code=400, detail="批量对比需要至少 2 个文件")
90
+ return EventSourceResponse(
91
+ extract_batch_generator(
92
+ req.fileIds,
93
+ req.provider or "deepseek",
94
+ req.model,
95
+ req.elements,
96
+ user_id=current_user["id"],
97
+ )
98
+ )
99
+
100
+
101
+ async def extract_threshold_generator(
102
+ file_id: str,
103
+ user_qualifications: str,
104
+ provider: str = "deepseek",
105
+ model: str = None,
106
+ user_id: str = None,
107
+ ):
108
+ extract_service = ExtractService()
109
+ async for event in extract_service.extract_threshold_stream(
110
+ file_id, user_qualifications, provider, model, user_id=user_id
111
+ ):
112
+ yield {
113
+ "event": event.get("type", "message"),
114
+ "data": json.dumps(event, ensure_ascii=False),
115
+ }
116
+
117
+
118
+ @router.post("/element/threshold")
119
+ @limiter.limit("20/minute")
120
+ async def extract_threshold(request: Request, current_user: dict = Depends(get_current_user)):
121
+ """门槛分析:招标文件要求 vs 用户自身条件逐项比对。限速 20次/分钟。"""
122
+ body = await request.json()
123
+ req = ThresholdRequest(**body)
124
+ if not req.userQualifications.strip():
125
+ raise HTTPException(status_code=400, detail="请填写自身资质、业绩条件")
126
+ return EventSourceResponse(
127
+ extract_threshold_generator(
128
+ req.fileId,
129
+ req.userQualifications,
130
+ req.provider or "deepseek",
131
+ req.model,
132
+ user_id=current_user["id"],
133
+ )
134
+ )
135
+
136
+
137
+ @router.get("/status/{task_id}")
138
+ async def get_extract_status(task_id: str, current_user: dict = Depends(get_current_user)):
139
+ """获取提取任务状态(兼容旧版 task_id 查询)。"""
140
+ from app.infrastructure.pg_storage import get_extract
141
+ result = await get_extract(task_id, user_id=current_user["id"])
142
+ if result:
143
+ return {"success": True, "data": result}
144
+ return {
145
+ "success": True,
146
+ "data": {"taskId": task_id, "status": "unknown", "progress": 0}
147
+ }
148
+
149
+
150
+ @router.get("/result/by-file/{file_id}")
151
+ async def get_extract_result_by_file(file_id: str, current_user: dict = Depends(get_current_user)):
152
+ """根据文件 ID 获取最新的提取结果。"""
153
+ from app.infrastructure.pg_storage import list_extracts
154
+ results = await list_extracts(page=1, page_size=50, user_id=current_user["id"])
155
+ for r in results.get("results", []):
156
+ if r.get("file_id") == file_id:
157
+ return {"success": True, "data": r}
158
+ return {"success": False, "data": None, "message": "未找到提取结果"}
app/api/files.py ADDED
@@ -0,0 +1,163 @@
1
+ """
2
+ File management API routes.
3
+ 所有端点强制认证,文件归属当前用户。
4
+ """
5
+ from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Query
6
+ from fastapi.responses import StreamingResponse
7
+ from pathlib import Path
8
+ from typing import Optional
9
+ from urllib.parse import quote
10
+
11
+ from app.services.file_service import FileService
12
+ from app.models.schemas import FileUploadResponse, FileListResponse, FileListItem
13
+ from app.utils.exceptions import FileTooLargeError, UnsupportedFileTypeError
14
+ from app.infrastructure.pg_storage import add_file, list_files as pg_list_files, get_file as pg_get_file, delete_file as pg_delete_file, _now, calculate_content_hash
15
+ from app.utils.auth_dep import get_current_user
16
+
17
+ router = APIRouter(prefix="/files", tags=["files"])
18
+
19
+ MIME_TYPE_EXTENSIONS = {
20
+ "application/pdf": "pdf",
21
+ "text/markdown": "md",
22
+ "application/msword": "doc",
23
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
24
+ "application/vnd.ms-excel": "xls",
25
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
26
+ "text/csv": "csv",
27
+ }
28
+
29
+
30
+ def normalize_file_type(filename: str, mime_type: str) -> str:
31
+ suffix = Path(filename or "").suffix.lower().lstrip(".")
32
+ if suffix:
33
+ return suffix[:50]
34
+ return MIME_TYPE_EXTENSIONS.get(mime_type, (mime_type.rsplit("/", 1)[-1] if mime_type else "file"))[:50]
35
+
36
+
37
+ @router.post("/upload")
38
+ async def upload_file(
39
+ file: UploadFile = File(...),
40
+ category: str = Query("tender", description="Document category: tender or bid"),
41
+ current_user: dict = Depends(get_current_user),
42
+ ):
43
+ """
44
+ Upload and encrypt a file.
45
+ """
46
+ try:
47
+ content = await file.read()
48
+ file_service = FileService()
49
+ result = await file_service.upload(
50
+ file_content=content,
51
+ filename=file.filename,
52
+ mime_type=file.content_type or "application/octet-stream",
53
+ category=category,
54
+ )
55
+
56
+ await add_file({
57
+ "id": result["id"],
58
+ "original_name": result["name"],
59
+ "path": result["encrypted_path"],
60
+ "size": result["size"],
61
+ "type": normalize_file_type(result["name"], result["mime_type"]),
62
+ "file_hash": calculate_content_hash(content),
63
+ "created_at": _now(),
64
+ }, user_id=current_user["id"], encrypted_content=result["encrypted_content"])
65
+
66
+ return {
67
+ "success": True,
68
+ "data": {
69
+ "id": result["id"],
70
+ "name": result["name"],
71
+ "size": result["size"],
72
+ "type": result["mime_type"],
73
+ "status": result["status"],
74
+ "createdAt": result.get("created_at", ""),
75
+ }
76
+ }
77
+
78
+ except FileTooLargeError as e:
79
+ raise HTTPException(status_code=413, detail=str(e))
80
+ except UnsupportedFileTypeError as e:
81
+ raise HTTPException(status_code=400, detail=str(e))
82
+ except Exception as e:
83
+ raise HTTPException(status_code=500, detail=str(e))
84
+
85
+
86
+ @router.get("/list")
87
+ async def list_files(
88
+ page: int = Query(1, ge=1),
89
+ limit: int = Query(10, ge=1, le=100),
90
+ file_type: Optional[str] = None,
91
+ current_user: dict = Depends(get_current_user),
92
+ ):
93
+ """List uploaded files."""
94
+ result = await pg_list_files(page, limit, file_type, user_id=current_user["id"])
95
+ return {
96
+ "success": True,
97
+ "data": {
98
+ "files": result["files"],
99
+ "total": result["total"],
100
+ },
101
+ "pagination": {
102
+ "page": result["page"],
103
+ "limit": limit,
104
+ "total": result["total"],
105
+ }
106
+ }
107
+
108
+
109
+ @router.get("/{file_id}")
110
+ async def get_file(file_id: str, current_user: dict = Depends(get_current_user)):
111
+ """Get file metadata."""
112
+ record = await pg_get_file(file_id, user_id=current_user["id"])
113
+ if not record:
114
+ raise HTTPException(status_code=404, detail="File not found")
115
+ return {
116
+ "success": True,
117
+ "data": {
118
+ "id": record["id"],
119
+ "name": record["original_name"],
120
+ "size": record["size"],
121
+ "mimeType": record.get("type", ""),
122
+ "status": "ready",
123
+ }
124
+ }
125
+
126
+
127
+ @router.get("/{file_id}/download")
128
+ async def download_file(file_id: str, current_user: dict = Depends(get_current_user)):
129
+ """Download and decrypt a file."""
130
+ record = await pg_get_file(file_id, user_id=current_user["id"])
131
+ if not record:
132
+ raise HTTPException(status_code=404, detail="File not found")
133
+ filename = record.get("original_name", f"{file_id}.pdf")
134
+ try:
135
+ file_service = FileService()
136
+ content = await file_service.download(file_id, current_user["id"])
137
+ encoded_filename = quote(filename)
138
+ return StreamingResponse(
139
+ iter([content]),
140
+ media_type="application/octet-stream",
141
+ headers={
142
+ "Content-Disposition": f"attachment; filename=\"{encoded_filename}\"; filename*=UTF-8''{encoded_filename}"
143
+ }
144
+ )
145
+ except FileNotFoundError:
146
+ raise HTTPException(status_code=404, detail="File not found")
147
+ except Exception as e:
148
+ raise HTTPException(status_code=500, detail=str(e))
149
+
150
+
151
+ @router.delete("/{file_id}")
152
+ async def delete_file(file_id: str, current_user: dict = Depends(get_current_user)):
153
+ """Delete an encrypted file."""
154
+ try:
155
+ file_service = FileService()
156
+ deleted = await file_service.delete(file_id)
157
+ await pg_delete_file(file_id, user_id=current_user["id"])
158
+ return {
159
+ "success": deleted,
160
+ "message": "File deleted" if deleted else "File not found"
161
+ }
162
+ except Exception as e:
163
+ raise HTTPException(status_code=500, detail=str(e))
app/api/health.py ADDED
@@ -0,0 +1,62 @@
1
+ """
2
+ Health check API route.
3
+ """
4
+ from fastapi import APIRouter
5
+ from datetime import datetime
6
+ import subprocess
7
+
8
+ from app.models.schemas import HealthResponse
9
+
10
+ router = APIRouter(tags=["health"])
11
+
12
+
13
+ def _get_git_info() -> dict:
14
+ """获取 git 信息用于部署验证。"""
15
+ info = {}
16
+ try:
17
+ info["commit"] = subprocess.check_output(
18
+ ["git", "rev-parse", "--short", "HEAD"], stderr=subprocess.DEVNULL
19
+ ).decode().strip()
20
+ except Exception:
21
+ info["commit"] = "unknown"
22
+ try:
23
+ info["branch"] = subprocess.check_output(
24
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"], stderr=subprocess.DEVNULL
25
+ ).decode().strip()
26
+ except Exception:
27
+ info["branch"] = "unknown"
28
+ return info
29
+
30
+
31
+ @router.get("/health")
32
+ async def health_check():
33
+ """
34
+ Health check endpoint.
35
+
36
+ Returns:
37
+ Service health status
38
+ """
39
+ git_info = _get_git_info()
40
+ return {
41
+ "status": "healthy",
42
+ "timestamp": datetime.utcnow().isoformat(),
43
+ "version": "1.0.0",
44
+ "git": git_info,
45
+ }
46
+
47
+
48
+ @router.get("/")
49
+ async def root():
50
+ """
51
+ Root endpoint.
52
+
53
+ Returns:
54
+ API information
55
+ """
56
+ git_info = _get_git_info()
57
+ return {
58
+ "name": "Bid Master API",
59
+ "version": "1.0.0",
60
+ "docs": "/docs",
61
+ "git": git_info,
62
+ }
app/api/logs.py ADDED
@@ -0,0 +1,26 @@
1
+ """
2
+ 日志查询 API 路由。
3
+ """
4
+ from fastapi import APIRouter, Depends, Query
5
+ from typing import Optional
6
+
7
+ from app.utils.auth_dep import get_current_user
8
+ from app.infrastructure.log_collector import get_logs, clear_logs
9
+
10
+ router = APIRouter(prefix="/logs", tags=["logs"])
11
+
12
+
13
+ @router.get("")
14
+ async def list_logs(
15
+ limit: int = Query(100, ge=1, le=500),
16
+ level: Optional[str] = Query(None),
17
+ current_user: dict = Depends(get_current_user),
18
+ ):
19
+ logs = get_logs(limit=limit, level=level, user_id=current_user["id"])
20
+ return {"success": True, "logs": logs}
21
+
22
+
23
+ @router.delete("")
24
+ async def delete_logs(current_user: dict = Depends(get_current_user)):
25
+ count = clear_logs(user_id=current_user["id"])
26
+ return {"success": True, "deleted": count}