gitinstall 1.1.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 (59) hide show
  1. gitinstall/__init__.py +61 -0
  2. gitinstall/_sdk.py +541 -0
  3. gitinstall/academic.py +831 -0
  4. gitinstall/admin.html +327 -0
  5. gitinstall/auto_update.py +384 -0
  6. gitinstall/autopilot.py +349 -0
  7. gitinstall/badge.py +476 -0
  8. gitinstall/checkpoint.py +330 -0
  9. gitinstall/cicd.py +499 -0
  10. gitinstall/clawhub.html +718 -0
  11. gitinstall/config_schema.py +353 -0
  12. gitinstall/db.py +984 -0
  13. gitinstall/db_backend.py +445 -0
  14. gitinstall/dep_chain.py +337 -0
  15. gitinstall/dependency_audit.py +1153 -0
  16. gitinstall/detector.py +542 -0
  17. gitinstall/doctor.py +493 -0
  18. gitinstall/education.py +869 -0
  19. gitinstall/enterprise.py +802 -0
  20. gitinstall/error_fixer.py +953 -0
  21. gitinstall/event_bus.py +251 -0
  22. gitinstall/executor.py +577 -0
  23. gitinstall/feature_flags.py +138 -0
  24. gitinstall/fetcher.py +921 -0
  25. gitinstall/huggingface.py +922 -0
  26. gitinstall/hw_detect.py +988 -0
  27. gitinstall/i18n.py +664 -0
  28. gitinstall/installer_registry.py +362 -0
  29. gitinstall/knowledge_base.py +379 -0
  30. gitinstall/license_check.py +605 -0
  31. gitinstall/llm.py +569 -0
  32. gitinstall/log.py +236 -0
  33. gitinstall/main.py +1408 -0
  34. gitinstall/mcp_agent.py +841 -0
  35. gitinstall/mcp_server.py +386 -0
  36. gitinstall/monorepo.py +810 -0
  37. gitinstall/multi_source.py +425 -0
  38. gitinstall/onboard.py +276 -0
  39. gitinstall/planner.py +222 -0
  40. gitinstall/planner_helpers.py +323 -0
  41. gitinstall/planner_known_projects.py +1010 -0
  42. gitinstall/planner_templates.py +996 -0
  43. gitinstall/remote_gpu.py +633 -0
  44. gitinstall/resilience.py +608 -0
  45. gitinstall/run_tests.py +572 -0
  46. gitinstall/skills.py +476 -0
  47. gitinstall/tool_schemas.py +324 -0
  48. gitinstall/trending.py +279 -0
  49. gitinstall/uninstaller.py +415 -0
  50. gitinstall/validate_top100.py +607 -0
  51. gitinstall/watchdog.py +180 -0
  52. gitinstall/web.py +1277 -0
  53. gitinstall/web_ui.html +2277 -0
  54. gitinstall-1.1.0.dist-info/METADATA +275 -0
  55. gitinstall-1.1.0.dist-info/RECORD +59 -0
  56. gitinstall-1.1.0.dist-info/WHEEL +5 -0
  57. gitinstall-1.1.0.dist-info/entry_points.txt +3 -0
  58. gitinstall-1.1.0.dist-info/licenses/LICENSE +21 -0
  59. gitinstall-1.1.0.dist-info/top_level.txt +1 -0
gitinstall/db.py ADDED
@@ -0,0 +1,984 @@
1
+ """
2
+ db.py - gitinstall 数据库模块
3
+ ==============================
4
+
5
+ 数据存储,支持 SQLite(默认)和 PostgreSQL 后端切换。
6
+ 零外部依赖(SQLite)。
7
+ 支持:匿名使用统计、用户注册/登录、配额管理、安装历史、密码重置、邮件发送。
8
+
9
+ 数据库位置(SQLite):~/.gitinstall/data.db
10
+ 后端选择:环境变量 GITINSTALL_DB_BACKEND = sqlite | postgresql
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import hmac
17
+ import html as _html
18
+ import json
19
+ import logging
20
+ import os
21
+ import secrets
22
+ import smtplib
23
+ import sqlite3
24
+ import threading
25
+ import time
26
+ from contextlib import contextmanager
27
+ from email.mime.multipart import MIMEMultipart
28
+ from email.mime.text import MIMEText
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from log import get_logger
33
+ from i18n import t
34
+ from db_backend import get_backend, DatabaseBackend
35
+
36
+ logger = get_logger(__name__)
37
+
38
+ # ── 旧路径常量(保持向后兼容) ──
39
+ DB_DIR = Path.home() / ".gitinstall"
40
+ DB_PATH = DB_DIR / "data.db"
41
+
42
+ # ── 线程安全连接池(通过后端抽象层管理) ──
43
+ _init_lock = threading.Lock()
44
+ _initialized = False
45
+
46
+
47
+ def _db() -> DatabaseBackend:
48
+ """获取当前数据库后端"""
49
+ return get_backend()
50
+
51
+
52
+ def _get_conn():
53
+ """获取当前线程的数据库连接(向后兼容)"""
54
+ return _db().get_connection()
55
+
56
+
57
+ @contextmanager
58
+ def _transaction():
59
+ """事务上下文管理器"""
60
+ with _db().transaction() as conn:
61
+ yield conn
62
+
63
+
64
+ # ─────────────────────────────────────────────
65
+ # Schema 初始化
66
+ # ─────────────────────────────────────────────
67
+
68
+ _SCHEMA = """
69
+ -- 使用事件(匿名统计)
70
+ CREATE TABLE IF NOT EXISTS events (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ ts REAL NOT NULL DEFAULT (strftime('%s','now')),
73
+ event_type TEXT NOT NULL,
74
+ project TEXT,
75
+ os_type TEXT,
76
+ detail TEXT,
77
+ user_id INTEGER,
78
+ ip_hash TEXT
79
+ );
80
+ CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
81
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
82
+
83
+ -- 用户
84
+ CREATE TABLE IF NOT EXISTS users (
85
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
86
+ username TEXT NOT NULL UNIQUE,
87
+ email TEXT NOT NULL UNIQUE,
88
+ pw_hash TEXT NOT NULL,
89
+ salt TEXT NOT NULL,
90
+ tier TEXT NOT NULL DEFAULT 'free',
91
+ is_admin INTEGER NOT NULL DEFAULT 0,
92
+ created_at REAL NOT NULL DEFAULT (strftime('%s','now')),
93
+ last_login REAL
94
+ );
95
+ CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
96
+
97
+ -- 月度用量
98
+ CREATE TABLE IF NOT EXISTS usage (
99
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
100
+ user_id INTEGER NOT NULL,
101
+ year_month TEXT NOT NULL,
102
+ plan_count INTEGER NOT NULL DEFAULT 0,
103
+ UNIQUE(user_id, year_month),
104
+ FOREIGN KEY(user_id) REFERENCES users(id)
105
+ );
106
+
107
+ -- 安装计划历史
108
+ CREATE TABLE IF NOT EXISTS plans_history (
109
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
110
+ ts REAL NOT NULL DEFAULT (strftime('%s','now')),
111
+ project TEXT NOT NULL,
112
+ strategy TEXT,
113
+ confidence TEXT,
114
+ steps_json TEXT,
115
+ success INTEGER,
116
+ duration REAL,
117
+ user_id INTEGER
118
+ );
119
+ CREATE INDEX IF NOT EXISTS idx_plans_project ON plans_history(project);
120
+
121
+ -- 配置键值对
122
+ CREATE TABLE IF NOT EXISTS config (
123
+ key TEXT PRIMARY KEY,
124
+ value TEXT
125
+ );
126
+
127
+ -- Session 独立表(企业级会话管理)
128
+ CREATE TABLE IF NOT EXISTS sessions (
129
+ token TEXT PRIMARY KEY,
130
+ user_id INTEGER NOT NULL REFERENCES users(id),
131
+ created_at REAL NOT NULL DEFAULT (strftime('%s','now')),
132
+ expires_at REAL NOT NULL,
133
+ ip_hash TEXT,
134
+ user_agent TEXT
135
+ );
136
+ CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
137
+ CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
138
+
139
+ -- 密码重置 token 独立表
140
+ CREATE TABLE IF NOT EXISTS reset_tokens (
141
+ token TEXT PRIMARY KEY,
142
+ user_id INTEGER NOT NULL REFERENCES users(id),
143
+ email TEXT NOT NULL,
144
+ created_at REAL NOT NULL DEFAULT (strftime('%s','now')),
145
+ expires_at REAL NOT NULL
146
+ );
147
+ CREATE INDEX IF NOT EXISTS idx_reset_expires ON reset_tokens(expires_at);
148
+
149
+ -- 安装智能追踪(数据飞轮核心表)
150
+ CREATE TABLE IF NOT EXISTS install_telemetry (
151
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
152
+ ts REAL NOT NULL DEFAULT (strftime('%s','now')),
153
+ project TEXT NOT NULL,
154
+ strategy TEXT,
155
+ os_type TEXT,
156
+ os_version TEXT,
157
+ arch TEXT,
158
+ gpu_type TEXT,
159
+ gpu_name TEXT,
160
+ vram_gb REAL,
161
+ cuda_version TEXT,
162
+ ram_gb REAL,
163
+ success INTEGER,
164
+ error_type TEXT,
165
+ error_message TEXT,
166
+ duration_sec REAL,
167
+ steps_total INTEGER,
168
+ steps_completed INTEGER,
169
+ env_hash TEXT
170
+ );
171
+ CREATE INDEX IF NOT EXISTS idx_telemetry_project ON install_telemetry(project);
172
+ CREATE INDEX IF NOT EXISTS idx_telemetry_gpu ON install_telemetry(gpu_type);
173
+ CREATE INDEX IF NOT EXISTS idx_telemetry_success ON install_telemetry(success);
174
+ """
175
+
176
+
177
+ def init_db():
178
+ """初始化数据库(幂等,可多次调用)"""
179
+ global _initialized
180
+ if _initialized:
181
+ return
182
+ with _init_lock:
183
+ if _initialized:
184
+ return
185
+ backend = _db()
186
+ schema = backend.adapt_schema(_SCHEMA)
187
+ backend.executescript(schema)
188
+ if backend.backend_type == "sqlite":
189
+ backend.get_connection().commit()
190
+ _initialized = True
191
+
192
+
193
+ # ─────────────────────────────────────────────
194
+ # 事件记录(匿名统计)
195
+ # ─────────────────────────────────────────────
196
+
197
+ def record_event(
198
+ event_type: str,
199
+ project: str = None,
200
+ os_type: str = None,
201
+ detail: dict = None,
202
+ user_id: int = None,
203
+ ip: str = None,
204
+ ):
205
+ """
206
+ 记录一个使用事件。
207
+
208
+ event_type 枚举:
209
+ - plan_generated 生成安装计划
210
+ - install_started 开始安装
211
+ - install_done 安装完成
212
+ - install_failed 安装失败
213
+ - search 搜索项目
214
+ - trending_view 查看热门
215
+ - page_view 访问首页
216
+ """
217
+ init_db()
218
+ ip_hash = hashlib.sha256(ip.encode()).hexdigest()[:16] if ip else None
219
+ detail_str = json.dumps(detail, ensure_ascii=False) if detail else None
220
+ with _transaction() as conn:
221
+ conn.execute(
222
+ "INSERT INTO events (event_type, project, os_type, detail, user_id, ip_hash) "
223
+ "VALUES (?, ?, ?, ?, ?, ?)",
224
+ (event_type, project, os_type, detail_str, user_id, ip_hash),
225
+ )
226
+
227
+
228
+ # ─────────────────────────────────────────────
229
+ # 统计查询
230
+ # ─────────────────────────────────────────────
231
+
232
+ def get_stats() -> dict:
233
+ """返回汇总统计信息"""
234
+ init_db()
235
+ conn = _get_conn()
236
+
237
+ def _scalar(sql: str, params=()) -> Any:
238
+ row = conn.execute(sql, params).fetchone()
239
+ return row[0] if row else 0
240
+
241
+ total_plans = _scalar("SELECT COUNT(*) FROM events WHERE event_type='plan_generated'")
242
+ total_installs = _scalar("SELECT COUNT(*) FROM events WHERE event_type='install_done'")
243
+ total_users = _scalar("SELECT COUNT(*) FROM users")
244
+
245
+ # 最近 7 天活跃
246
+ week_ago = time.time() - 7 * 86400
247
+ active_7d = _scalar(
248
+ "SELECT COUNT(DISTINCT ip_hash) FROM events WHERE ts > ? AND ip_hash IS NOT NULL",
249
+ (week_ago,),
250
+ )
251
+
252
+ # 热门项目 TOP 10
253
+ top_projects = [
254
+ {"project": r[0], "count": r[1]}
255
+ for r in conn.execute(
256
+ "SELECT project, COUNT(*) as cnt FROM events "
257
+ "WHERE event_type='plan_generated' AND project IS NOT NULL "
258
+ "GROUP BY project ORDER BY cnt DESC LIMIT 10"
259
+ ).fetchall()
260
+ ]
261
+
262
+ # OS 分布
263
+ os_dist = [
264
+ {"os": r[0] or "unknown", "count": r[1]}
265
+ for r in conn.execute(
266
+ "SELECT os_type, COUNT(*) as cnt FROM events "
267
+ "WHERE event_type='plan_generated' "
268
+ "GROUP BY os_type ORDER BY cnt DESC LIMIT 5"
269
+ ).fetchall()
270
+ ]
271
+
272
+ # 每日趋势(近 30 天)
273
+ month_ago = time.time() - 30 * 86400
274
+ daily_trend = [
275
+ {"date": r[0], "count": r[1]}
276
+ for r in conn.execute(
277
+ "SELECT date(ts, 'unixepoch', 'localtime') as d, COUNT(*) "
278
+ "FROM events WHERE ts > ? "
279
+ "GROUP BY d ORDER BY d",
280
+ (month_ago,),
281
+ ).fetchall()
282
+ ]
283
+
284
+ # 安装成功率
285
+ success = _scalar("SELECT COUNT(*) FROM events WHERE event_type='install_done'")
286
+ failed = _scalar("SELECT COUNT(*) FROM events WHERE event_type='install_failed'")
287
+ success_rate = round(success / max(success + failed, 1) * 100, 1)
288
+
289
+ return {
290
+ "total_plans": total_plans,
291
+ "total_installs": total_installs,
292
+ "total_users": total_users,
293
+ "active_7d": active_7d,
294
+ "success_rate": success_rate,
295
+ "top_projects": top_projects,
296
+ "os_distribution": os_dist,
297
+ "daily_trend": daily_trend,
298
+ }
299
+
300
+
301
+ # ─────────────────────────────────────────────
302
+ # 用户管理
303
+ # ─────────────────────────────────────────────
304
+
305
+ def _hash_password(password: str, salt: str, iterations: int = 600_000) -> str:
306
+ """PBKDF2 密码哈希"""
307
+ return hashlib.pbkdf2_hmac(
308
+ "sha256", password.encode(), salt.encode(), iterations
309
+ ).hex()
310
+
311
+
312
+ def register_user(username: str, email: str, password: str) -> dict:
313
+ """
314
+ 注册新用户。
315
+ 返回 {"status": "ok", "user_id": ...} 或 {"status": "error", "message": ...}
316
+ """
317
+ init_db()
318
+ username = username.strip()
319
+ email = email.strip().lower()
320
+
321
+ if not username or not email or not password:
322
+ return {"status": "error", "message": t("auth.fields_required")}
323
+ if len(password) < 8:
324
+ return {"status": "error", "message": t("auth.password_min", n=8)}
325
+
326
+ salt = secrets.token_hex(16)
327
+ pw_hash = _hash_password(password, salt)
328
+
329
+ try:
330
+ with _transaction() as conn:
331
+ conn.execute(
332
+ "INSERT INTO users (username, email, pw_hash, salt) VALUES (?, ?, ?, ?)",
333
+ (username, email, pw_hash, salt),
334
+ )
335
+ # 跨后端获取新插入 ID
336
+ if _db().backend_type == "postgresql":
337
+ user_id = conn.execute("SELECT currval(pg_get_serial_sequence('users','id'))").fetchone()["currval"]
338
+ else:
339
+ user_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
340
+ return {"status": "ok", "user_id": user_id}
341
+ except (sqlite3.IntegrityError, Exception) as e:
342
+ # 捕获 SQLite 和 PostgreSQL 的唯一约束冲突
343
+ msg = str(e).lower()
344
+ if "unique" in msg or "duplicate" in msg or "integrity" in msg:
345
+ if "email" in msg:
346
+ return {"status": "error", "message": t("auth.email_exists")}
347
+ if "username" in msg:
348
+ return {"status": "error", "message": t("auth.username_taken")}
349
+ return {"status": "error", "message": t("auth.register_failed")}
350
+ raise
351
+
352
+
353
+ def login_user(email: str, password: str) -> dict:
354
+ """
355
+ 用户登录。
356
+ 返回 {"status": "ok", "user_id": ..., "username": ..., "tier": ..., "token": ...}
357
+ """
358
+ init_db()
359
+ email = email.strip().lower()
360
+ conn = _get_conn()
361
+ row = conn.execute(
362
+ "SELECT id, username, pw_hash, salt, tier FROM users WHERE email = ?",
363
+ (email,),
364
+ ).fetchone()
365
+
366
+ if not row:
367
+ return {"status": "error", "message": t("auth.invalid_credentials")}
368
+
369
+ pw_hash = _hash_password(password, row["salt"])
370
+ if not hmac.compare_digest(pw_hash, row["pw_hash"]):
371
+ # 兼容旧版 100k 迭代的密码哈希
372
+ pw_hash_legacy = _hash_password(password, row["salt"], iterations=100_000)
373
+ if not hmac.compare_digest(pw_hash_legacy, row["pw_hash"]):
374
+ return {"status": "error", "message": t("auth.invalid_credentials")}
375
+ # 自动升级到 600k 迭代
376
+ new_salt = secrets.token_hex(16)
377
+ new_hash = _hash_password(password, new_salt)
378
+ conn.execute(
379
+ "UPDATE users SET pw_hash = ?, salt = ? WHERE id = ?",
380
+ (new_hash, new_salt, row["id"]),
381
+ )
382
+ conn.commit()
383
+ return {"status": "error", "message": t("auth.invalid_credentials")}
384
+
385
+ # 更新最后登录时间
386
+ conn.execute("UPDATE users SET last_login = strftime('%s','now') WHERE id = ?", (row["id"],))
387
+ conn.commit()
388
+
389
+ # 生成 session token → 写入 sessions 独立表
390
+ token = secrets.token_urlsafe(32)
391
+ now = time.time()
392
+ expires_at = now + 7 * 86400 # 7 天过期
393
+ conn.execute(
394
+ "INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
395
+ (token, row["id"], now, expires_at),
396
+ )
397
+ conn.commit()
398
+
399
+ return {
400
+ "status": "ok",
401
+ "user_id": row["id"],
402
+ "username": row["username"],
403
+ "tier": row["tier"],
404
+ "token": token,
405
+ }
406
+
407
+
408
+ def validate_token(token: str) -> dict | None:
409
+ """验证 session token,返回 user 信息或 None"""
410
+ if not token:
411
+ return None
412
+ init_db()
413
+ conn = _get_conn()
414
+ now = time.time()
415
+
416
+ # 优先查 sessions 独立表
417
+ row = conn.execute(
418
+ "SELECT user_id, expires_at FROM sessions WHERE token = ?", (token,)
419
+ ).fetchone()
420
+ if row:
421
+ if now > row["expires_at"]:
422
+ conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
423
+ conn.commit()
424
+ return None
425
+ user = conn.execute(
426
+ "SELECT id, username, email, tier, is_admin FROM users WHERE id = ?",
427
+ (row["user_id"],),
428
+ ).fetchone()
429
+ return dict(user) if user else None
430
+
431
+ # 向后兼容:查旧 config 表中的 session(自动迁移)
432
+ legacy = conn.execute(
433
+ "SELECT value FROM config WHERE key = ?", (f"session:{token}",)
434
+ ).fetchone()
435
+ if legacy:
436
+ data = json.loads(legacy["value"])
437
+ if now - data["ts"] > 7 * 86400:
438
+ conn.execute("DELETE FROM config WHERE key = ?", (f"session:{token}",))
439
+ conn.commit()
440
+ return None
441
+ # 迁移到新表
442
+ expires_at = data["ts"] + 7 * 86400
443
+ try:
444
+ conn.execute(
445
+ "INSERT OR IGNORE INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
446
+ (token, data["user_id"], data["ts"], expires_at),
447
+ )
448
+ conn.execute("DELETE FROM config WHERE key = ?", (f"session:{token}",))
449
+ conn.commit()
450
+ except Exception:
451
+ pass
452
+ user = conn.execute(
453
+ "SELECT id, username, email, tier, is_admin FROM users WHERE id = ?",
454
+ (data["user_id"],),
455
+ ).fetchone()
456
+ return dict(user) if user else None
457
+
458
+ return None
459
+
460
+
461
+ def is_admin(token: str) -> bool:
462
+ """检查 token 对应的用户是否为管理员"""
463
+ user = validate_token(token)
464
+ return bool(user and user.get("is_admin"))
465
+
466
+
467
+ def set_admin(user_id: int, value: bool = True):
468
+ """设置/取消管理员权限"""
469
+ init_db()
470
+ conn = _get_conn()
471
+ conn.execute("UPDATE users SET is_admin = ? WHERE id = ?", (1 if value else 0, user_id))
472
+ conn.commit()
473
+
474
+
475
+ def cleanup_expired_sessions():
476
+ """清理过期的 session 和重置 token"""
477
+ init_db()
478
+ conn = _get_conn()
479
+ now = time.time()
480
+ total_cleaned = 0
481
+
482
+ # 清理 sessions 独立表中的过期 session
483
+ cur = conn.execute("DELETE FROM sessions WHERE expires_at < ?", (now,))
484
+ total_cleaned += cur.rowcount
485
+
486
+ # 清理 reset_tokens 独立表中的过期 token
487
+ cur = conn.execute("DELETE FROM reset_tokens WHERE expires_at < ?", (now,))
488
+ total_cleaned += cur.rowcount
489
+
490
+ # 向后兼容:清理旧 config 表中遗留的 session/reset 数据
491
+ rows = conn.execute("SELECT key, value FROM config WHERE key LIKE 'session:%'").fetchall()
492
+ expired = []
493
+ for row in rows:
494
+ try:
495
+ data = json.loads(row["value"])
496
+ if now - data.get("ts", 0) > 7 * 86400:
497
+ expired.append(row["key"])
498
+ except (json.JSONDecodeError, TypeError):
499
+ expired.append(row["key"])
500
+ rows2 = conn.execute("SELECT key, value FROM config WHERE key LIKE 'reset:%'").fetchall()
501
+ for row in rows2:
502
+ try:
503
+ data = json.loads(row["value"])
504
+ if now - data.get("ts", 0) > 1800:
505
+ expired.append(row["key"])
506
+ except (json.JSONDecodeError, TypeError):
507
+ expired.append(row["key"])
508
+ if expired:
509
+ conn.executemany("DELETE FROM config WHERE key = ?", [(k,) for k in expired])
510
+ total_cleaned += len(expired)
511
+
512
+ conn.commit()
513
+ return total_cleaned
514
+
515
+ # ─────────────────────────────────────────────
516
+ # 密码重置
517
+ # ─────────────────────────────────────────────
518
+
519
+ def create_reset_token(email: str) -> dict:
520
+ """
521
+ 为指定邮箱创建密码重置 token。
522
+ 返回 {"status": "ok", "token": ..., "username": ...} 或 {"status": "error", ...}
523
+ """
524
+ init_db()
525
+ email = email.strip().lower()
526
+ conn = _get_conn()
527
+ user = conn.execute(
528
+ "SELECT id, username FROM users WHERE email = ?", (email,)
529
+ ).fetchone()
530
+ if not user:
531
+ # 不透露邮箱是否存在,统一返回 ok
532
+ return {"status": "ok", "token": None, "username": None}
533
+
534
+ token = secrets.token_urlsafe(32)
535
+ now = time.time()
536
+ expires_at = now + 30 * 60 # 30 分钟有效
537
+ conn.execute(
538
+ "INSERT INTO reset_tokens (token, user_id, email, created_at, expires_at) VALUES (?, ?, ?, ?, ?)",
539
+ (token, user["id"], email, now, expires_at),
540
+ )
541
+ conn.commit()
542
+ return {"status": "ok", "token": token, "username": user["username"]}
543
+
544
+
545
+ def verify_reset_token(token: str) -> dict | None:
546
+ """验证重置 token,返回 {user_id, email} 或 None(30 分钟有效)"""
547
+ if not token:
548
+ return None
549
+ init_db()
550
+ conn = _get_conn()
551
+ now = time.time()
552
+
553
+ # 优先查 reset_tokens 独立表
554
+ row = conn.execute(
555
+ "SELECT user_id, email, expires_at FROM reset_tokens WHERE token = ?", (token,)
556
+ ).fetchone()
557
+ if row:
558
+ if now > row["expires_at"]:
559
+ conn.execute("DELETE FROM reset_tokens WHERE token = ?", (token,))
560
+ conn.commit()
561
+ return None
562
+ return {"user_id": row["user_id"], "email": row["email"]}
563
+
564
+ # 向后兼容:查旧 config 表
565
+ legacy = conn.execute(
566
+ "SELECT value FROM config WHERE key = ?", (f"reset:{token}",)
567
+ ).fetchone()
568
+ if legacy:
569
+ data = json.loads(legacy["value"])
570
+ if now - data["ts"] > 30 * 60:
571
+ conn.execute("DELETE FROM config WHERE key = ?", (f"reset:{token}",))
572
+ conn.commit()
573
+ return None
574
+ return {"user_id": data["user_id"], "email": data["email"]}
575
+
576
+ return None
577
+
578
+
579
+ def reset_password(token: str, new_password: str) -> dict:
580
+ """
581
+ 通过重置 token 修改密码。
582
+ 返回 {"status": "ok"} 或 {"status": "error", "message": ...}
583
+ """
584
+ if len(new_password) < 8:
585
+ return {"status": "error", "message": t("auth.password_min", n=8)}
586
+
587
+ if not token:
588
+ return {"status": "error", "message": t("auth.reset_expired")}
589
+
590
+ init_db()
591
+
592
+ # 原子操作:验证 + 删除 token + 更新密码在同一事务中
593
+ salt = secrets.token_hex(16)
594
+ pw_hash = _hash_password(new_password, salt)
595
+
596
+ with _transaction() as conn:
597
+ # 优先查 reset_tokens 独立表
598
+ row = conn.execute(
599
+ "SELECT user_id, expires_at FROM reset_tokens WHERE token = ?", (token,)
600
+ ).fetchone()
601
+ if row:
602
+ if time.time() > row["expires_at"]:
603
+ conn.execute("DELETE FROM reset_tokens WHERE token = ?", (token,))
604
+ return {"status": "error", "message": t("auth.reset_expired")}
605
+ conn.execute(
606
+ "UPDATE users SET pw_hash = ?, salt = ? WHERE id = ?",
607
+ (pw_hash, salt, row["user_id"]),
608
+ )
609
+ conn.execute("DELETE FROM reset_tokens WHERE token = ?", (token,))
610
+ return {"status": "ok", "message": t("auth.password_reset_ok")}
611
+
612
+ # 向后兼容:查旧 config 表
613
+ legacy = conn.execute(
614
+ "SELECT value FROM config WHERE key = ?", (f"reset:{token}",)
615
+ ).fetchone()
616
+ if not legacy:
617
+ return {"status": "error", "message": t("auth.reset_expired")}
618
+
619
+ data = json.loads(legacy["value"])
620
+ if time.time() - data["ts"] > 30 * 60:
621
+ conn.execute("DELETE FROM config WHERE key = ?", (f"reset:{token}",))
622
+ return {"status": "error", "message": t("auth.reset_expired")}
623
+
624
+ conn.execute(
625
+ "UPDATE users SET pw_hash = ?, salt = ? WHERE id = ?",
626
+ (pw_hash, salt, data["user_id"]),
627
+ )
628
+ conn.execute("DELETE FROM config WHERE key = ?", (f"reset:{token}",))
629
+
630
+ return {"status": "ok", "message": t("auth.password_reset_ok")}
631
+
632
+ # ─────────────────────────────────────────────
633
+ # 配额管理
634
+ # ─────────────────────────────────────────────
635
+
636
+ # 每月免费次数
637
+ TIER_LIMITS = {
638
+ "guest": 5, # 未注册
639
+ "free": 20, # 注册用户
640
+ "pro": -1, # 无限制
641
+ }
642
+
643
+
644
+ def _current_month() -> str:
645
+ import datetime
646
+ return datetime.datetime.now().strftime("%Y-%m")
647
+
648
+
649
+ def check_quota(user_id: int | None = None, ip: str = None) -> dict:
650
+ """
651
+ 检查使用配额。
652
+ 返回 {"allowed": bool, "used": int, "limit": int, "tier": str}
653
+ """
654
+ init_db()
655
+ conn = _get_conn()
656
+ month = _current_month()
657
+
658
+ if user_id:
659
+ user = conn.execute("SELECT tier FROM users WHERE id = ?", (user_id,)).fetchone()
660
+ tier = user["tier"] if user else "guest"
661
+ else:
662
+ tier = "guest"
663
+
664
+ limit = TIER_LIMITS.get(tier, 5)
665
+
666
+ if limit < 0: # 无限制
667
+ return {"allowed": True, "used": 0, "limit": -1, "tier": tier}
668
+
669
+ if user_id:
670
+ row = conn.execute(
671
+ "SELECT plan_count FROM usage WHERE user_id = ? AND year_month = ?",
672
+ (user_id, month),
673
+ ).fetchone()
674
+ used = row["plan_count"] if row else 0
675
+ else:
676
+ # 匿名用户按 IP 统计
677
+ if not ip:
678
+ return {"allowed": True, "used": 0, "limit": limit, "tier": tier}
679
+ ip_hash = hashlib.sha256(ip.encode()).hexdigest()[:16]
680
+ month_start = time.time() - 30 * 86400 # 近似
681
+ row = conn.execute(
682
+ "SELECT COUNT(*) FROM events "
683
+ "WHERE ip_hash = ? AND event_type = 'plan_generated' AND ts > ?",
684
+ (ip_hash, month_start),
685
+ ).fetchone()
686
+ used = row[0] if row else 0
687
+
688
+ return {
689
+ "allowed": used < limit,
690
+ "used": used,
691
+ "limit": limit,
692
+ "tier": tier,
693
+ }
694
+
695
+
696
+ def increment_usage(user_id: int):
697
+ """增加已注册用户的月度用量"""
698
+ init_db()
699
+ month = _current_month()
700
+ with _transaction() as conn:
701
+ conn.execute(
702
+ "INSERT INTO usage (user_id, year_month, plan_count) VALUES (?, ?, 1) "
703
+ "ON CONFLICT(user_id, year_month) DO UPDATE SET plan_count = plan_count + 1",
704
+ (user_id, month),
705
+ )
706
+
707
+
708
+ # ─────────────────────────────────────────────
709
+ # 安装历史
710
+ # ─────────────────────────────────────────────
711
+
712
+ def save_plan_history(
713
+ project: str,
714
+ strategy: str = None,
715
+ confidence: str = None,
716
+ steps: list = None,
717
+ success: bool = None,
718
+ duration: float = None,
719
+ user_id: int = None,
720
+ ):
721
+ """保存安装计划到历史记录"""
722
+ init_db()
723
+ steps_json = json.dumps(steps, ensure_ascii=False) if steps else None
724
+ with _transaction() as conn:
725
+ conn.execute(
726
+ "INSERT INTO plans_history (project, strategy, confidence, steps_json, success, duration, user_id) "
727
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
728
+ (project, strategy, confidence, steps_json,
729
+ 1 if success else (0 if success is not None else None),
730
+ duration, user_id),
731
+ )
732
+
733
+
734
+ def get_recent_installs(limit: int = 20) -> list[dict]:
735
+ """获取最近的安装记录"""
736
+ init_db()
737
+ conn = _get_conn()
738
+ rows = conn.execute(
739
+ "SELECT project, strategy, confidence, success, duration, ts "
740
+ "FROM plans_history ORDER BY ts DESC LIMIT ?",
741
+ (min(limit, 100),),
742
+ ).fetchall()
743
+ return [
744
+ {
745
+ "project": r["project"],
746
+ "strategy": r["strategy"],
747
+ "confidence": r["confidence"],
748
+ "success": bool(r["success"]) if r["success"] is not None else None,
749
+ "duration": r["duration"],
750
+ "time": r["ts"],
751
+ }
752
+ for r in rows
753
+ ]
754
+
755
+
756
+ # ─────────────────────────────────────────────
757
+ # 邮件发送
758
+ # ─────────────────────────────────────────────
759
+
760
+ # 邮件配置通过环境变量:
761
+ # GITINSTALL_SMTP_HOST (默认 smtp.example.com)
762
+ # GITINSTALL_SMTP_PORT (默认 465, SSL)
763
+ # GITINSTALL_SMTP_USER 发件邮箱
764
+ # GITINSTALL_SMTP_PASS 授权码/密码
765
+ # GITINSTALL_SMTP_FROM 发件人显示名 (默认 "gitinstall")
766
+ # GITINSTALL_BASE_URL 站点地址 (默认 http://127.0.0.1:8080)
767
+
768
+ def _get_smtp_config() -> dict | None:
769
+ """获取 SMTP 配置,未配置则返回 None"""
770
+ user = os.environ.get("GITINSTALL_SMTP_USER", "")
771
+ passwd = os.environ.get("GITINSTALL_SMTP_PASS", "")
772
+ if not user or not passwd:
773
+ return None
774
+ return {
775
+ "host": os.environ.get("GITINSTALL_SMTP_HOST", "smtp.example.com"),
776
+ "port": int(os.environ.get("GITINSTALL_SMTP_PORT", "465")),
777
+ "user": user,
778
+ "password": passwd,
779
+ "from_name": os.environ.get("GITINSTALL_SMTP_FROM", "gitinstall"),
780
+ }
781
+
782
+
783
+ def send_email(to_email: str, subject: str, html_body: str) -> bool:
784
+ """
785
+ 发送 HTML 邮件。
786
+ 返回 True 表示已发送,False 表示 SMTP 未配置或发送失败。
787
+ """
788
+ cfg = _get_smtp_config()
789
+ if not cfg:
790
+ return False
791
+
792
+ msg = MIMEMultipart("alternative")
793
+ msg["Subject"] = subject
794
+ msg["From"] = f"{cfg['from_name']} <{cfg['user']}>"
795
+ msg["To"] = to_email
796
+ msg.attach(MIMEText(html_body, "html", "utf-8"))
797
+
798
+ try:
799
+ with smtplib.SMTP_SSL(cfg["host"], cfg["port"], timeout=10) as s:
800
+ s.login(cfg["user"], cfg["password"])
801
+ s.sendmail(cfg["user"], [to_email], msg.as_string())
802
+ return True
803
+ except Exception:
804
+ return False
805
+
806
+
807
+ def send_welcome_email(to_email: str, username: str) -> bool:
808
+ """注册成功后发送欢迎邮件"""
809
+ base_url = os.environ.get("GITINSTALL_BASE_URL", "http://127.0.0.1:8080")
810
+ safe_user = _html.escape(username)
811
+ safe_email = _html.escape(to_email)
812
+ html = f"""\
813
+ <div style="max-width:480px;margin:0 auto;font-family:system-ui,sans-serif;color:#333">
814
+ <h2 style="color:#8b5cf6">{t("email.welcome_greeting")}</h2>
815
+ <p>Hi <strong>{safe_user}</strong>,</p>
816
+ <p>{t("email.register_success")}</p>
817
+ <p>{t("email.account_info")}</p>
818
+ <ul>
819
+ <li>用户名:<strong>{safe_user}</strong></li>
820
+ <li>邮箱:{safe_email}</li>
821
+ <li>{t("email.tier_free")}</li>
822
+ </ul>
823
+ <p>
824
+ <a href="{base_url}" style="display:inline-block;padding:8px 20px;background:#8b5cf6;color:#fff;border-radius:6px;text-decoration:none">
825
+ {t("email.start_using")}
826
+ </a>
827
+ </p>
828
+ <hr style="border:none;border-top:1px solid #eee;margin:20px 0">
829
+ <p style="font-size:12px;color:#999">
830
+ {t("email.forgot_password_hint")}<br>
831
+ {t("email.auto_sent")}
832
+ </p>
833
+ </div>"""
834
+ return send_email(to_email, t("email.welcome_subject"), html)
835
+
836
+
837
+ def send_reset_email(to_email: str, username: str, reset_token: str) -> bool:
838
+ """发送密码重置邮件"""
839
+ base_url = os.environ.get("GITINSTALL_BASE_URL", "http://127.0.0.1:8080")
840
+ reset_url = f"{base_url}?reset_token={reset_token}"
841
+ safe_user = _html.escape(username)
842
+ html = f"""\
843
+ <div style="max-width:480px;margin:0 auto;font-family:system-ui,sans-serif;color:#333">
844
+ <h2 style="color:#8b5cf6">{t("email.reset_title")}</h2>
845
+ <p>Hi <strong>{safe_user}</strong>,</p>
846
+ <p>{t("email.reset_request")}</p>
847
+ <p>
848
+ <a href="{reset_url}" style="display:inline-block;padding:10px 24px;background:#8b5cf6;color:#fff;border-radius:6px;text-decoration:none;font-weight:600">
849
+ {t("email.reset_button")}
850
+ </a>
851
+ </p>
852
+ <p style="font-size:13px;color:#666">
853
+ {t("email.reset_validity")}
854
+ </p>
855
+ <hr style="border:none;border-top:1px solid #eee;margin:20px 0">
856
+ <p style="font-size:12px;color:#999">
857
+ {t("email.reset_fallback")}<br>
858
+ <span style="word-break:break-all">{reset_url}</span>
859
+ </p>
860
+ </div>"""
861
+ return send_email(to_email, t("email.reset_subject"), html)
862
+
863
+
864
+ # ─────────────────────────────────────────────
865
+ # 安装智能追踪(数据飞轮)
866
+ # ─────────────────────────────────────────────
867
+
868
+ def record_install_telemetry(
869
+ project: str,
870
+ strategy: str = None,
871
+ gpu_info: dict = None,
872
+ env: dict = None,
873
+ success: bool = None,
874
+ error_type: str = None,
875
+ error_message: str = None,
876
+ duration_sec: float = None,
877
+ steps_total: int = None,
878
+ steps_completed: int = None,
879
+ ):
880
+ """
881
+ 记录安装遥测数据。每次安装尝试都应调用此函数。
882
+ 这些数据用于:
883
+ - 安装成功率统计(按项目/OS/GPU 维度)
884
+ - 智能推荐优化
885
+ - 常见错误模式识别
886
+ """
887
+ init_db()
888
+ os_info = (env or {}).get("os", {})
889
+ hw = (env or {}).get("hardware", {})
890
+ gpu = gpu_info or {}
891
+ env_hash = hashlib.sha256(
892
+ json.dumps({"os": os_info.get("type"), "gpu": gpu.get("type"),
893
+ "arch": os_info.get("arch")}, sort_keys=True).encode()
894
+ ).hexdigest()[:16]
895
+
896
+ with _transaction() as conn:
897
+ conn.execute(
898
+ "INSERT INTO install_telemetry "
899
+ "(project, strategy, os_type, os_version, arch, gpu_type, gpu_name, "
900
+ "vram_gb, cuda_version, ram_gb, success, error_type, error_message, "
901
+ "duration_sec, steps_total, steps_completed, env_hash) "
902
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
903
+ (
904
+ project, strategy,
905
+ os_info.get("type"), os_info.get("version"), os_info.get("arch"),
906
+ gpu.get("type"), gpu.get("name"),
907
+ gpu.get("vram_gb"), gpu.get("cuda_version"),
908
+ hw.get("ram_gb"),
909
+ 1 if success else (0 if success is not None else None),
910
+ error_type,
911
+ (error_message or "")[:500], # 限制长度
912
+ duration_sec,
913
+ steps_total, steps_completed, env_hash,
914
+ ),
915
+ )
916
+
917
+
918
+ def get_project_success_rate(project: str) -> dict:
919
+ """
920
+ 查询项目的安装成功率(按 GPU 类型分组)。
921
+
922
+ Returns:
923
+ {
924
+ "overall": {"total": int, "success": int, "rate": float},
925
+ "by_gpu": {"nvidia": {"total": .., "rate": ..}, ...},
926
+ "by_os": {"macos": {"total": .., "rate": ..}, ...},
927
+ "common_errors": [{"error_type": str, "count": int}, ...],
928
+ }
929
+ """
930
+ init_db()
931
+ conn = _get_conn()
932
+ project = project.lower()
933
+
934
+ # 总体成功率
935
+ total = conn.execute(
936
+ "SELECT COUNT(*) FROM install_telemetry WHERE project = ? AND success IS NOT NULL",
937
+ (project,)
938
+ ).fetchone()[0]
939
+ success = conn.execute(
940
+ "SELECT COUNT(*) FROM install_telemetry WHERE project = ? AND success = 1",
941
+ (project,)
942
+ ).fetchone()[0]
943
+
944
+ # 按 GPU 类型
945
+ by_gpu = {}
946
+ for row in conn.execute(
947
+ "SELECT gpu_type, COUNT(*) as cnt, SUM(CASE WHEN success=1 THEN 1 ELSE 0 END) as ok "
948
+ "FROM install_telemetry WHERE project = ? AND success IS NOT NULL AND gpu_type IS NOT NULL "
949
+ "GROUP BY gpu_type",
950
+ (project,)
951
+ ).fetchall():
952
+ by_gpu[row[0]] = {"total": row[1], "success": row[2], "rate": round(row[2] / max(row[1], 1) * 100, 1)}
953
+
954
+ # 按 OS
955
+ by_os = {}
956
+ for row in conn.execute(
957
+ "SELECT os_type, COUNT(*) as cnt, SUM(CASE WHEN success=1 THEN 1 ELSE 0 END) as ok "
958
+ "FROM install_telemetry WHERE project = ? AND success IS NOT NULL AND os_type IS NOT NULL "
959
+ "GROUP BY os_type",
960
+ (project,)
961
+ ).fetchall():
962
+ by_os[row[0]] = {"total": row[1], "success": row[2], "rate": round(row[2] / max(row[1], 1) * 100, 1)}
963
+
964
+ # 常见错误
965
+ common_errors = [
966
+ {"error_type": row[0], "count": row[1]}
967
+ for row in conn.execute(
968
+ "SELECT error_type, COUNT(*) as cnt FROM install_telemetry "
969
+ "WHERE project = ? AND success = 0 AND error_type IS NOT NULL "
970
+ "GROUP BY error_type ORDER BY cnt DESC LIMIT 5",
971
+ (project,)
972
+ ).fetchall()
973
+ ]
974
+
975
+ return {
976
+ "overall": {
977
+ "total": total,
978
+ "success": success,
979
+ "rate": round(success / max(total, 1) * 100, 1),
980
+ },
981
+ "by_gpu": by_gpu,
982
+ "by_os": by_os,
983
+ "common_errors": common_errors,
984
+ }