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.
- app/__init__.py +1 -0
- app/api/__init__.py +1 -0
- app/api/api_keys.py +60 -0
- app/api/auth.py +258 -0
- app/api/cli_auth.py +165 -0
- app/api/database.py +286 -0
- app/api/extract.py +158 -0
- app/api/files.py +163 -0
- app/api/health.py +62 -0
- app/api/logs.py +26 -0
- app/api/settings.py +101 -0
- app/api/simulate.py +195 -0
- app/api/statistics.py +1214 -0
- app/cli.py +894 -0
- app/config.py +93 -0
- app/dependencies.py +12 -0
- app/infrastructure/__init__.py +1 -0
- app/infrastructure/database.py +126 -0
- app/infrastructure/db_schema.py +245 -0
- app/infrastructure/email_service.py +92 -0
- app/infrastructure/llm/__init__.py +1 -0
- app/infrastructure/llm/lite_llm.py +463 -0
- app/infrastructure/log_collector.py +64 -0
- app/infrastructure/mock_storage.py +563 -0
- app/infrastructure/pg_storage.py +656 -0
- app/infrastructure/storage.py +117 -0
- app/limiter.py +7 -0
- app/main.py +141 -0
- app/models/__init__.py +1 -0
- app/models/schemas.py +204 -0
- app/services/__init__.py +1 -0
- app/services/encryption_service.py +88 -0
- app/services/extract_service.py +817 -0
- app/services/file_service.py +112 -0
- app/services/llm_service.py +65 -0
- app/services/ocr_service.py +183 -0
- app/services/prompt_builder.py +257 -0
- app/services/simulate_service.py +625 -0
- app/services/statistics_service.py +123 -0
- app/utils/__init__.py +1 -0
- app/utils/auth_dep.py +42 -0
- app/utils/crypto.py +63 -0
- app/utils/exceptions.py +53 -0
- bid_master_cli-1.0.0.dist-info/METADATA +30 -0
- bid_master_cli-1.0.0.dist-info/RECORD +47 -0
- bid_master_cli-1.0.0.dist-info/WHEEL +4 -0
- bid_master_cli-1.0.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Persistent mock data storage with JSON file backing.
|
|
3
|
+
Data is kept in memory for fast reads and persisted to disk on writes.
|
|
4
|
+
Survives container restarts and sleep/wake cycles.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import uuid
|
|
9
|
+
import threading
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
# --- Config ---
|
|
14
|
+
# 默认使用项目目录下的 data/,避免 /tmp 在部署重启时被清空
|
|
15
|
+
_DEFAULT_DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "data")
|
|
16
|
+
DATA_DIR = os.environ.get("DATA_DIR", _DEFAULT_DATA_DIR)
|
|
17
|
+
STORAGE_FILE = os.path.join(DATA_DIR, "mock_storage.json")
|
|
18
|
+
|
|
19
|
+
# --- In-memory dicts (loaded from disk on startup) ---
|
|
20
|
+
_mock_files: dict = {}
|
|
21
|
+
_mock_simulates: dict = {}
|
|
22
|
+
_mock_openings: dict = {}
|
|
23
|
+
_mock_extracts: dict = {}
|
|
24
|
+
_mock_users: dict = {}
|
|
25
|
+
_mock_api_keys: dict = {}
|
|
26
|
+
|
|
27
|
+
DEMO_USER_ID = "demo-user"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _now() -> str:
|
|
31
|
+
return datetime.now(timezone.utc).isoformat()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --- Persistence ---
|
|
35
|
+
_save_lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_from_disk():
|
|
39
|
+
"""Load all data from JSON file on startup."""
|
|
40
|
+
global _mock_files, _mock_simulates, _mock_openings, _mock_extracts, _mock_users, _mock_api_keys
|
|
41
|
+
if not os.path.exists(STORAGE_FILE):
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
with open(STORAGE_FILE, "r", encoding="utf-8") as f:
|
|
45
|
+
data = json.load(f)
|
|
46
|
+
_mock_files = data.get("files", {})
|
|
47
|
+
_mock_simulates = data.get("simulates", {})
|
|
48
|
+
_mock_openings = data.get("openings", {})
|
|
49
|
+
_mock_extracts = data.get("extracts", {})
|
|
50
|
+
_mock_users = data.get("users", {})
|
|
51
|
+
_mock_api_keys = data.get("api_keys", {})
|
|
52
|
+
except (json.JSONDecodeError, OSError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _save_to_disk():
|
|
57
|
+
"""Persist all data to JSON file."""
|
|
58
|
+
os.makedirs(DATA_DIR, exist_ok=True)
|
|
59
|
+
with _save_lock:
|
|
60
|
+
try:
|
|
61
|
+
data = {
|
|
62
|
+
"files": _mock_files,
|
|
63
|
+
"simulates": _mock_simulates,
|
|
64
|
+
"openings": _mock_openings,
|
|
65
|
+
"extracts": _mock_extracts,
|
|
66
|
+
"users": _mock_users,
|
|
67
|
+
"api_keys": _mock_api_keys,
|
|
68
|
+
}
|
|
69
|
+
with open(STORAGE_FILE, "w", encoding="utf-8") as f:
|
|
70
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _init_mock_data():
|
|
76
|
+
"""Initialize with sample data for development (only if no persisted data)."""
|
|
77
|
+
if _mock_users:
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# Files
|
|
81
|
+
f1_id = str(uuid.uuid4())[:8]
|
|
82
|
+
f2_id = str(uuid.uuid4())[:8]
|
|
83
|
+
_mock_files[f1_id] = {
|
|
84
|
+
"id": f1_id,
|
|
85
|
+
"original_name": "招标文件-第一包.pdf",
|
|
86
|
+
"path": f"/storage/{f1_id}.enc",
|
|
87
|
+
"size": 1024000,
|
|
88
|
+
"type": "pdf",
|
|
89
|
+
"user_id": DEMO_USER_ID,
|
|
90
|
+
"created_at": _now(),
|
|
91
|
+
}
|
|
92
|
+
_mock_files[f2_id] = {
|
|
93
|
+
"id": f2_id,
|
|
94
|
+
"original_name": "投标文件-A公司.docx",
|
|
95
|
+
"path": f"/storage/{f2_id}.enc",
|
|
96
|
+
"size": 2048000,
|
|
97
|
+
"type": "word",
|
|
98
|
+
"user_id": DEMO_USER_ID,
|
|
99
|
+
"created_at": _now(),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Simulate tasks
|
|
103
|
+
s1_id = str(uuid.uuid4())[:8]
|
|
104
|
+
_mock_simulates[s1_id] = {
|
|
105
|
+
"task_id": s1_id,
|
|
106
|
+
"status": "completed",
|
|
107
|
+
"current_step": 4,
|
|
108
|
+
"params": {"template_type": "construction"},
|
|
109
|
+
"step_results": {
|
|
110
|
+
"step1": "PDF 转换完成",
|
|
111
|
+
"step2": "要素提取完成",
|
|
112
|
+
"step3": "对比分析完成",
|
|
113
|
+
"step4": "模拟编制完成",
|
|
114
|
+
},
|
|
115
|
+
"file_ids": [f1_id],
|
|
116
|
+
"files": [{"id": f1_id, "original_name": "招标文件-第一包.pdf", "type": "pdf"}],
|
|
117
|
+
"user_id": DEMO_USER_ID,
|
|
118
|
+
"created_at": _now(),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Opening results
|
|
122
|
+
o1_id = str(uuid.uuid4())[:8]
|
|
123
|
+
_mock_openings[o1_id] = {
|
|
124
|
+
"id": o1_id,
|
|
125
|
+
"file_id": f1_id,
|
|
126
|
+
"bidder_count": 5,
|
|
127
|
+
"bid_ranking": [
|
|
128
|
+
{"rank": 1, "name": "A公司", "price": 1150000},
|
|
129
|
+
{"rank": 2, "name": "B公司", "price": 1180000},
|
|
130
|
+
{"rank": 3, "name": "C公司", "price": 1250000},
|
|
131
|
+
{"rank": 4, "name": "D公司", "price": 1280000},
|
|
132
|
+
{"rank": 5, "name": "E公司", "price": 1320000},
|
|
133
|
+
],
|
|
134
|
+
"bid_stats": {
|
|
135
|
+
"mean": 1236000,
|
|
136
|
+
"std": 64000,
|
|
137
|
+
"cv": 5.2,
|
|
138
|
+
"min": 1150000,
|
|
139
|
+
"max": 1320000,
|
|
140
|
+
"range": 170000,
|
|
141
|
+
},
|
|
142
|
+
"user_id": DEMO_USER_ID,
|
|
143
|
+
"created_at": _now(),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
# Extract results
|
|
147
|
+
e1_id = str(uuid.uuid4())[:8]
|
|
148
|
+
_mock_extracts[e1_id] = {
|
|
149
|
+
"id": e1_id,
|
|
150
|
+
"file_id": f1_id,
|
|
151
|
+
"template_type": "construction",
|
|
152
|
+
"mode": "fast",
|
|
153
|
+
"content": """## 资质要求
|
|
154
|
+
投标人须具有建筑工程施工总承包一级资质
|
|
155
|
+
|
|
156
|
+
## 评标办法
|
|
157
|
+
综合评估法,商务标占60%,技术标占40%
|
|
158
|
+
|
|
159
|
+
## 业绩门槛
|
|
160
|
+
近三年内完成过三个以上类似项目
|
|
161
|
+
|
|
162
|
+
## 定标方法
|
|
163
|
+
最低价中标法
|
|
164
|
+
|
|
165
|
+
## 合同条款
|
|
166
|
+
合同工期12个月,付款方式为按月进度支付""",
|
|
167
|
+
"user_id": DEMO_USER_ID,
|
|
168
|
+
"created_at": _now(),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
_save_to_disk()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Load persisted data first, then seed demo data if empty
|
|
175
|
+
_load_from_disk()
|
|
176
|
+
_init_mock_data()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --- CRUD Helpers (所有查询按 user_id 隔离) ---
|
|
180
|
+
|
|
181
|
+
def get_stats(user_id: str) -> dict:
|
|
182
|
+
return {
|
|
183
|
+
"files": sum(1 for r in _mock_files.values() if r.get("user_id") == user_id),
|
|
184
|
+
"simulate_tasks": sum(1 for r in _mock_simulates.values() if r.get("user_id") == user_id),
|
|
185
|
+
"opening_results": sum(1 for r in _mock_openings.values() if r.get("user_id") == user_id),
|
|
186
|
+
"extract_results": sum(1 for r in _mock_extracts.values() if r.get("user_id") == user_id),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def list_files(page: int = 1, page_size: int = 20, file_type: Optional[str] = None, user_id: Optional[str] = None) -> dict:
|
|
191
|
+
records = [r for r in _mock_files.values() if r.get("user_id") == user_id]
|
|
192
|
+
if file_type:
|
|
193
|
+
records = [r for r in records if r["type"] == file_type]
|
|
194
|
+
records.sort(key=lambda r: r["created_at"], reverse=True)
|
|
195
|
+
total = len(records)
|
|
196
|
+
start = (page - 1) * page_size
|
|
197
|
+
end = start + page_size
|
|
198
|
+
return {
|
|
199
|
+
"total": total,
|
|
200
|
+
"page": page,
|
|
201
|
+
"page_size": page_size,
|
|
202
|
+
"files": records[start:end],
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def get_file(file_id: str, user_id: Optional[str] = None) -> Optional[dict]:
|
|
207
|
+
record = _mock_files.get(file_id)
|
|
208
|
+
if record and user_id and record.get("user_id") != user_id:
|
|
209
|
+
return None
|
|
210
|
+
return record
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def delete_file(file_id: str, user_id: Optional[str] = None) -> bool:
|
|
214
|
+
record = _mock_files.get(file_id)
|
|
215
|
+
if not record:
|
|
216
|
+
return False
|
|
217
|
+
if user_id and record.get("user_id") != user_id:
|
|
218
|
+
return False
|
|
219
|
+
del _mock_files[file_id]
|
|
220
|
+
_save_to_disk()
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def list_simulates(page: int = 1, page_size: int = 20, status: Optional[str] = None, user_id: Optional[str] = None) -> dict:
|
|
225
|
+
records = [r for r in _mock_simulates.values() if r.get("user_id") == user_id]
|
|
226
|
+
if status:
|
|
227
|
+
records = [r for r in records if r["status"] == status]
|
|
228
|
+
records.sort(key=lambda r: r["created_at"], reverse=True)
|
|
229
|
+
total = len(records)
|
|
230
|
+
start = (page - 1) * page_size
|
|
231
|
+
end = start + page_size
|
|
232
|
+
return {
|
|
233
|
+
"total": total,
|
|
234
|
+
"page": page,
|
|
235
|
+
"page_size": page_size,
|
|
236
|
+
"tasks": records[start:end],
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def get_simulate(task_id: str, user_id: Optional[str] = None) -> Optional[dict]:
|
|
241
|
+
record = _mock_simulates.get(task_id)
|
|
242
|
+
if record and user_id and record.get("user_id") != user_id:
|
|
243
|
+
return None
|
|
244
|
+
return record
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def delete_simulate(task_id: str, user_id: Optional[str] = None) -> bool:
|
|
248
|
+
record = _mock_simulates.get(task_id)
|
|
249
|
+
if not record:
|
|
250
|
+
return False
|
|
251
|
+
if user_id and record.get("user_id") != user_id:
|
|
252
|
+
return False
|
|
253
|
+
del _mock_simulates[task_id]
|
|
254
|
+
_save_to_disk()
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def list_openings(page: int = 1, page_size: int = 20, user_id: Optional[str] = None) -> dict:
|
|
259
|
+
records = [r for r in _mock_openings.values() if r.get("user_id") == user_id]
|
|
260
|
+
records.sort(key=lambda r: r["created_at"], reverse=True)
|
|
261
|
+
total = len(records)
|
|
262
|
+
start = (page - 1) * page_size
|
|
263
|
+
end = start + page_size
|
|
264
|
+
return {
|
|
265
|
+
"total": total,
|
|
266
|
+
"page": page,
|
|
267
|
+
"page_size": page_size,
|
|
268
|
+
"results": records[start:end],
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_opening(task_id: str, user_id: Optional[str] = None) -> Optional[dict]:
|
|
273
|
+
record = _mock_openings.get(task_id)
|
|
274
|
+
if record and user_id and record.get("user_id") != user_id:
|
|
275
|
+
return None
|
|
276
|
+
return record
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def delete_opening(task_id: str, user_id: Optional[str] = None) -> bool:
|
|
280
|
+
record = _mock_openings.get(task_id)
|
|
281
|
+
if not record:
|
|
282
|
+
return False
|
|
283
|
+
if user_id and record.get("user_id") != user_id:
|
|
284
|
+
return False
|
|
285
|
+
del _mock_openings[task_id]
|
|
286
|
+
_save_to_disk()
|
|
287
|
+
return True
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def update_opening(opening_id: str, updates: dict) -> bool:
|
|
291
|
+
"""Update an opening analysis record."""
|
|
292
|
+
if opening_id in _mock_openings:
|
|
293
|
+
_mock_openings[opening_id].update(updates)
|
|
294
|
+
_save_to_disk()
|
|
295
|
+
return True
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def list_extracts(page: int = 1, page_size: int = 20, user_id: Optional[str] = None) -> dict:
|
|
300
|
+
records = [r for r in _mock_extracts.values() if r.get("user_id") == user_id]
|
|
301
|
+
records.sort(key=lambda r: r["created_at"], reverse=True)
|
|
302
|
+
total = len(records)
|
|
303
|
+
start = (page - 1) * page_size
|
|
304
|
+
end = start + page_size
|
|
305
|
+
return {
|
|
306
|
+
"total": total,
|
|
307
|
+
"page": page,
|
|
308
|
+
"page_size": page_size,
|
|
309
|
+
"results": records[start:end],
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def get_extract(result_id: str, user_id: Optional[str] = None) -> Optional[dict]:
|
|
314
|
+
record = _mock_extracts.get(result_id)
|
|
315
|
+
if record and user_id and record.get("user_id") != user_id:
|
|
316
|
+
return None
|
|
317
|
+
return record
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def delete_extract(result_id: str, user_id: Optional[str] = None) -> bool:
|
|
321
|
+
record = _mock_extracts.get(result_id)
|
|
322
|
+
if not record:
|
|
323
|
+
return False
|
|
324
|
+
if user_id and record.get("user_id") != user_id:
|
|
325
|
+
return False
|
|
326
|
+
del _mock_extracts[result_id]
|
|
327
|
+
_save_to_disk()
|
|
328
|
+
return True
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# --- Write Helpers (register data from other modules) ---
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def add_file(record: dict, user_id: Optional[str] = None) -> dict:
|
|
335
|
+
"""Register a file record (from upload or other source)."""
|
|
336
|
+
if "id" not in record:
|
|
337
|
+
record["id"] = str(uuid.uuid4())[:8]
|
|
338
|
+
if "created_at" not in record:
|
|
339
|
+
record["created_at"] = _now()
|
|
340
|
+
if user_id:
|
|
341
|
+
record["user_id"] = user_id
|
|
342
|
+
_mock_files[record["id"]] = record
|
|
343
|
+
_save_to_disk()
|
|
344
|
+
return record
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def add_simulate(record: dict, user_id: Optional[str] = None) -> dict:
|
|
348
|
+
"""Register a simulate task record."""
|
|
349
|
+
if "task_id" not in record:
|
|
350
|
+
record["task_id"] = str(uuid.uuid4())[:8]
|
|
351
|
+
if "created_at" not in record:
|
|
352
|
+
record["created_at"] = _now()
|
|
353
|
+
if "name" not in record:
|
|
354
|
+
ts = datetime.now().strftime("%m%d_%H%M")
|
|
355
|
+
file_names = record.get("file_names") or []
|
|
356
|
+
file_part = "_".join(n.rsplit(".", 1)[0] for n in file_names[:2]) if file_names else ""
|
|
357
|
+
record["name"] = f"模拟编制_{file_part}_{ts}" if file_part else f"模拟编制_{ts}"
|
|
358
|
+
if user_id:
|
|
359
|
+
record["user_id"] = user_id
|
|
360
|
+
_mock_simulates[record["task_id"]] = record
|
|
361
|
+
_save_to_disk()
|
|
362
|
+
return record
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def add_opening(record: dict, user_id: Optional[str] = None) -> dict:
|
|
366
|
+
"""Register an opening analysis result."""
|
|
367
|
+
if "id" not in record:
|
|
368
|
+
record["id"] = str(uuid.uuid4())[:8]
|
|
369
|
+
if "created_at" not in record:
|
|
370
|
+
record["created_at"] = _now()
|
|
371
|
+
if "name" not in record:
|
|
372
|
+
ts = datetime.now().strftime("%m%d_%H%M")
|
|
373
|
+
file_name = record.get("file_name", "")
|
|
374
|
+
if file_name:
|
|
375
|
+
file_part = file_name.rsplit(".", 1)[0]
|
|
376
|
+
record["name"] = f"开标分析_{file_part}_{ts}"
|
|
377
|
+
else:
|
|
378
|
+
project_name = (record.get("meta") or {}).get("project_name", "")
|
|
379
|
+
record["name"] = f"开标分析_{project_name}_{ts}" if project_name else f"开标分析_{ts}"
|
|
380
|
+
if user_id:
|
|
381
|
+
record["user_id"] = user_id
|
|
382
|
+
_mock_openings[record["id"]] = record
|
|
383
|
+
_save_to_disk()
|
|
384
|
+
return record
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def add_extract(record: dict, user_id: Optional[str] = None) -> dict:
|
|
388
|
+
"""Register an extraction result."""
|
|
389
|
+
if "id" not in record:
|
|
390
|
+
record["id"] = str(uuid.uuid4())[:8]
|
|
391
|
+
if "created_at" not in record:
|
|
392
|
+
record["created_at"] = _now()
|
|
393
|
+
if "name" not in record:
|
|
394
|
+
ts = datetime.now().strftime("%m%d_%H%M")
|
|
395
|
+
file_name = record.get("file_name", "")
|
|
396
|
+
if file_name:
|
|
397
|
+
file_part = file_name.rsplit(".", 1)[0]
|
|
398
|
+
record["name"] = f"要素提取_{file_part}_{ts}"
|
|
399
|
+
else:
|
|
400
|
+
template = record.get("template_type", "")
|
|
401
|
+
record["name"] = f"要素提取_{template}_{ts}" if template else f"要素提取_{ts}"
|
|
402
|
+
if user_id:
|
|
403
|
+
record["user_id"] = user_id
|
|
404
|
+
_mock_extracts[record["id"]] = record
|
|
405
|
+
_save_to_disk()
|
|
406
|
+
return record
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def update_file(file_id: str, updates: dict) -> bool:
|
|
410
|
+
"""Update a file record."""
|
|
411
|
+
if file_id in _mock_files:
|
|
412
|
+
_mock_files[file_id].update(updates)
|
|
413
|
+
_save_to_disk()
|
|
414
|
+
return True
|
|
415
|
+
return False
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def update_simulate(task_id: str, updates: dict) -> bool:
|
|
419
|
+
"""Update a simulate task record."""
|
|
420
|
+
if task_id in _mock_simulates:
|
|
421
|
+
_mock_simulates[task_id].update(updates)
|
|
422
|
+
_save_to_disk()
|
|
423
|
+
return True
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# --- User Helpers ---
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def add_user(record: dict) -> dict:
|
|
431
|
+
"""Register a user record."""
|
|
432
|
+
if "id" not in record:
|
|
433
|
+
record["id"] = str(uuid.uuid4())[:8]
|
|
434
|
+
if "created_at" not in record:
|
|
435
|
+
record["created_at"] = _now()
|
|
436
|
+
_mock_users[record["id"]] = record
|
|
437
|
+
_save_to_disk()
|
|
438
|
+
return record
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def get_user_by_username(username: str) -> Optional[dict]:
|
|
442
|
+
"""Find a user by username."""
|
|
443
|
+
for user in _mock_users.values():
|
|
444
|
+
if user.get("username") == username:
|
|
445
|
+
return user
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def get_user_by_email(email: str) -> Optional[dict]:
|
|
450
|
+
"""Find a user by email."""
|
|
451
|
+
for user in _mock_users.values():
|
|
452
|
+
if user.get("email") == email:
|
|
453
|
+
return user
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def get_user_by_id(user_id: str) -> Optional[dict]:
|
|
458
|
+
"""Find a user by ID."""
|
|
459
|
+
return _mock_users.get(user_id)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# ==============================================
|
|
463
|
+
# API Key Storage
|
|
464
|
+
# ==============================================
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def save_api_key(user_id: str, provider: str, encrypted_key: str) -> dict:
|
|
468
|
+
"""Save or update an encrypted API key for a user/provider pair."""
|
|
469
|
+
key = f"{user_id}:{provider}"
|
|
470
|
+
record = {
|
|
471
|
+
"user_id": user_id,
|
|
472
|
+
"provider": provider,
|
|
473
|
+
"encrypted_key": encrypted_key,
|
|
474
|
+
"updated_at": _now(),
|
|
475
|
+
}
|
|
476
|
+
_mock_api_keys[key] = record
|
|
477
|
+
_save_to_disk()
|
|
478
|
+
return record
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def get_api_key(user_id: str, provider: str) -> Optional[str]:
|
|
482
|
+
"""Get encrypted API key for a user/provider pair. Returns None if not found."""
|
|
483
|
+
record = _mock_api_keys.get(f"{user_id}:{provider}")
|
|
484
|
+
return record["encrypted_key"] if record else None
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def delete_api_key(user_id: str, provider: str) -> bool:
|
|
488
|
+
"""Delete API key for a user/provider pair."""
|
|
489
|
+
key = f"{user_id}:{provider}"
|
|
490
|
+
if key in _mock_api_keys:
|
|
491
|
+
del _mock_api_keys[key]
|
|
492
|
+
_save_to_disk()
|
|
493
|
+
return True
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def list_user_api_keys(user_id: str) -> list[dict]:
|
|
498
|
+
"""List all providers for which a user has stored keys (no sensitive data returned)."""
|
|
499
|
+
return [
|
|
500
|
+
{"provider": v["provider"], "updated_at": v["updated_at"]}
|
|
501
|
+
for v in _mock_api_keys.values()
|
|
502
|
+
if v["user_id"] == user_id
|
|
503
|
+
]
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# ==============================================
|
|
507
|
+
# Verification Code Storage (in-memory, not persisted)
|
|
508
|
+
# ==============================================
|
|
509
|
+
_verification_codes: dict = {}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def save_verification_code(email: str, code: str, expires_at: datetime) -> None:
|
|
513
|
+
_verification_codes[email] = {"code": code, "expires_at": expires_at.isoformat()}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def verify_code(email: str, code: str) -> bool:
|
|
517
|
+
record = _verification_codes.get(email)
|
|
518
|
+
if not record:
|
|
519
|
+
return False
|
|
520
|
+
if record["code"] != code:
|
|
521
|
+
return False
|
|
522
|
+
if datetime.fromisoformat(record["expires_at"]) < datetime.now(timezone.utc):
|
|
523
|
+
del _verification_codes[email]
|
|
524
|
+
return False
|
|
525
|
+
return True
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def delete_verification_code(email: str) -> None:
|
|
529
|
+
_verification_codes.pop(email, None)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ==============================================
|
|
533
|
+
# Password Reset Token Storage (in-memory, not persisted)
|
|
534
|
+
# ==============================================
|
|
535
|
+
_reset_tokens: dict = {}
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def save_reset_token(token: str, user_id: str, expires_at: datetime) -> None:
|
|
539
|
+
_reset_tokens[token] = {"user_id": user_id, "expires_at": expires_at.isoformat()}
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def get_reset_token(token: str) -> Optional[dict]:
|
|
543
|
+
record = _reset_tokens.get(token)
|
|
544
|
+
if not record:
|
|
545
|
+
return None
|
|
546
|
+
if datetime.fromisoformat(record["expires_at"]) < datetime.now(timezone.utc):
|
|
547
|
+
del _reset_tokens[token]
|
|
548
|
+
return None
|
|
549
|
+
return record
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def delete_reset_token(token: str) -> None:
|
|
553
|
+
_reset_tokens.pop(token, None)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def update_user_password(user_id: str, password_hash: str, salt_hex: str) -> bool:
|
|
557
|
+
user = _mock_users.get(user_id)
|
|
558
|
+
if not user:
|
|
559
|
+
return False
|
|
560
|
+
user["password_hash"] = password_hash
|
|
561
|
+
user["salt"] = salt_hex
|
|
562
|
+
_save_to_disk()
|
|
563
|
+
return True
|