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.
- gitinstall/__init__.py +61 -0
- gitinstall/_sdk.py +541 -0
- gitinstall/academic.py +831 -0
- gitinstall/admin.html +327 -0
- gitinstall/auto_update.py +384 -0
- gitinstall/autopilot.py +349 -0
- gitinstall/badge.py +476 -0
- gitinstall/checkpoint.py +330 -0
- gitinstall/cicd.py +499 -0
- gitinstall/clawhub.html +718 -0
- gitinstall/config_schema.py +353 -0
- gitinstall/db.py +984 -0
- gitinstall/db_backend.py +445 -0
- gitinstall/dep_chain.py +337 -0
- gitinstall/dependency_audit.py +1153 -0
- gitinstall/detector.py +542 -0
- gitinstall/doctor.py +493 -0
- gitinstall/education.py +869 -0
- gitinstall/enterprise.py +802 -0
- gitinstall/error_fixer.py +953 -0
- gitinstall/event_bus.py +251 -0
- gitinstall/executor.py +577 -0
- gitinstall/feature_flags.py +138 -0
- gitinstall/fetcher.py +921 -0
- gitinstall/huggingface.py +922 -0
- gitinstall/hw_detect.py +988 -0
- gitinstall/i18n.py +664 -0
- gitinstall/installer_registry.py +362 -0
- gitinstall/knowledge_base.py +379 -0
- gitinstall/license_check.py +605 -0
- gitinstall/llm.py +569 -0
- gitinstall/log.py +236 -0
- gitinstall/main.py +1408 -0
- gitinstall/mcp_agent.py +841 -0
- gitinstall/mcp_server.py +386 -0
- gitinstall/monorepo.py +810 -0
- gitinstall/multi_source.py +425 -0
- gitinstall/onboard.py +276 -0
- gitinstall/planner.py +222 -0
- gitinstall/planner_helpers.py +323 -0
- gitinstall/planner_known_projects.py +1010 -0
- gitinstall/planner_templates.py +996 -0
- gitinstall/remote_gpu.py +633 -0
- gitinstall/resilience.py +608 -0
- gitinstall/run_tests.py +572 -0
- gitinstall/skills.py +476 -0
- gitinstall/tool_schemas.py +324 -0
- gitinstall/trending.py +279 -0
- gitinstall/uninstaller.py +415 -0
- gitinstall/validate_top100.py +607 -0
- gitinstall/watchdog.py +180 -0
- gitinstall/web.py +1277 -0
- gitinstall/web_ui.html +2277 -0
- gitinstall-1.1.0.dist-info/METADATA +275 -0
- gitinstall-1.1.0.dist-info/RECORD +59 -0
- gitinstall-1.1.0.dist-info/WHEEL +5 -0
- gitinstall-1.1.0.dist-info/entry_points.txt +3 -0
- gitinstall-1.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
}
|