Jelajahi Sumber

docs: TG 协议号导入设计方案

把 D:\spider\tgs\ 下的中文"协议号"接入 TG 账号管理后台:
Pyrogram SQLite → gotd session 一次性转换、设备指纹随账号存储、
2FA AES-GCM 加密入库、前端 webkitdirectory 目录上传。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dot 2 minggu lalu
induk
melakukan
9fe0294257

+ 376 - 0
docs/superpowers/specs/2026-04-20-tg-protocol-number-import-design.md

@@ -0,0 +1,376 @@
+# TG 协议号导入设计
+
+**日期:** 2026-04-20
+**作者:** Claude (opus-4-7[1m]) + 用户
+**状态:** 已批准,待实现
+
+## 背景与目标
+
+`D:\spider\tgs\` 目录下有 5 个中文 TG 圈常见的"协议号"文件夹,每个以手机号命名,内容为:
+
+- `<phone>.json` — 包含 `api_id` / `api_hash` / `phone` / 设备指纹 / `twoFA` 密码
+- `<phone>.session` — **Pyrogram/Telethon SQLite 格式**(28672 字节),存 `dc_id` + 256 字节 `auth_key`
+- `2fa_password.txt` — 2FA 明文
+- `tdata/` — Telegram Desktop 客户端数据目录(备用)
+
+当前 `tg_accounts` 管理后台只支持手填 `session_file` 路径 + `app_id` + `app_hash`,而且 `gotd/td` 的 `session.FileStorage` 只认 **gotd 自有 JSON 格式**,与协议号的 Pyrogram SQLite **不兼容**。本设计要把协议号接入管理后台,让群克隆、采集等现有 TG 功能能直接用这些账号。
+
+## 核心设计决策(用户已确认)
+
+| 决策点 | 选择 |
+|---|---|
+| 录入入口 | 后台表单"上传整个协议号文件夹" |
+| 上传方式 | HTML5 `<input webkitdirectory>` 选目录,前端拆成多文件 multipart |
+| Session 源 | 优先 `.session`(Pyrogram SQLite),失败则 fallback `tdata/` |
+| 2FA 处理 | 只存不用:AES-GCM 加密后入库,供未来重登/导出 |
+| 设备指纹 | 必须存并在 `Connect` 时带上,防 TG 风控踢号 |
+| 重复手机号 | 拒绝(409),不覆盖 |
+| 导入后行为 | 自动测试连接,失败标 `enabled=false` 不回滚 |
+
+## 架构概览
+
+**核心思路:一次性转换 + gotd 原生运行时。**
+
+```
+前端 TgAccounts 页面
+    │
+    ├─ "导入协议号"按钮
+    │     用 <input webkitdirectory> 让用户选 D:\spider\tgs\<phone>\ 目录
+    │     前端把目录下所有文件(*.json / *.session / 2fa_password.txt / tdata/**)
+    │     逐个 append 到 FormData 上传
+    │
+    ▼
+POST /tg-accounts/import  (multipart/form-data)
+    │
+    ├─ Step 1. 收件:解析 multipart,按文件名/扩展名分类到临时目录 tmp/<uuid>/
+    ├─ Step 2. 读 <phone>.json → phone, api_id, api_hash, twoFA, device fingerprint
+    ├─ Step 3. 重复校验:phone 在 tg_accounts 表 → 409
+    ├─ Step 4. 转换 session:
+    │     4a. 优先尝试 Pyrogram SQLite(dc_id + auth_key → gotd session.Data → JSON)
+    │     4b. 失败则 fallback 尝试 tdata(用 gotd/td/session/tdesktop 包)
+    │     4c. 两者都失败 → 返回 422,原始文件保留在 tmp/ 方便排查
+    ├─ Step 5. 物化:
+    │     sessions/<phone>.json         ← 转换后的 gotd session
+    │     sessions/<phone>.origin/      ← 原始 .session + tdata/ 备份
+    ├─ Step 6. 入库:TgAccount 记录(加密 2FA + 设备字段)
+    ├─ Step 7. 自动测试:用新记录构造 Client.Connect + GetMe
+    │     失败 → enabled=false, status="dead", 返回告警但不回滚(账号还在库里)
+    └─ Step 8. reloadAccounts() → AccountManager 更新
+    ▼
+返回 { account, test_result }
+
+运行时:
+    AccountManager.Acquire → ManagedAccount.Client.Connect
+    Connect 读 sessions/<phone>.json(gotd 原生 FileStorage)
+    InitConnection 带上 DB 里的 device / app_version / sdk / lang_pack / lang_code
+```
+
+**关键不变量:**
+- 运行时 `telegram.Client` 不感知 Pyrogram/tdata —— 只认 gotd session JSON
+- 所有元数据(设备指纹、加密 2FA)都在 `tg_accounts` 表,不依赖硬盘上的原始 `.json`
+- `sessions/<phone>.origin/` 仅用于人工排障和未来迁移,运行时不读
+
+## §1 数据模型变更
+
+**`tg_accounts` 表新增字段**(通过 GORM auto-migrate 加入):
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `two_fa_enc` | `varbinary(255)` NULL | AES-GCM 加密后的 2FA 密码;`nil` 表示该账号无 2FA 或未上传 |
+| `device` | `varchar(100)` NULL | 如 "Xiaomi Mix 4" |
+| `app_version` | `varchar(50)` NULL | "12.0.0 (6163)" |
+| `sdk` | `varchar(100)` NULL | "Android 13 (33)"(映射到 gotd 的 `SystemVersion`) |
+| `lang_pack` | `varchar(50)` NULL | "android" |
+| `lang_code` | `varchar(20)` NULL | "en" |
+| `system_lang_code` | `varchar(20)` NULL | "en-US" |
+| `first_name` | `varchar(100)` NULL | UI 展示用 |
+| `last_name` | `varchar(100)` NULL | UI 展示用 |
+| `username` | `varchar(100)` NULL | UI 展示用 |
+| `origin_dir` | `varchar(500)` NULL | `sessions/<phone>.origin/` 绝对路径,排障用 |
+| `import_status` | `varchar(20)` NULL | `ok` / `session_invalid` / `dead`;nullable 以兼容旧记录 |
+| `source` | `varchar(20)` NULL | `protocol` / `manual`,区分两种录入方式;nullable 同上 |
+
+**不改:** `session_file` 保持语义(指向 `sessions/<phone>.json`),`app_id`/`app_hash` 继续复用。
+
+**对老记录:** 新字段全部 nullable,老记录仍可用;设备指纹走 gotd 默认值。
+
+## §2 文件布局与 Docker 卷
+
+容器内路径:
+```
+/app/sessions/                 ← 宿主机挂载卷
+├── 13252753163.json           ← gotd 原生格式(运行时读这个)
+├── 13252753163.origin/        ← 原始备份
+│   ├── 13252753163.json       ← Pyrogram 元数据
+│   ├── 13252753163.session    ← Pyrogram SQLite
+│   ├── 2fa_password.txt
+│   └── tdata/
+```
+
+**`docker-compose.yml` 改动**:确保 `sessions/` 目录是宿主机挂载卷;已存在则不动,没有就追加 `./data/sessions:/app/sessions`。
+
+**Config 新增**(`configs/config.yaml`):
+```yaml
+telegram:
+  sessions_dir: "/app/sessions"
+  secret_key: "${TG_SECRET_KEY}"   # 32 字节 base64,AES-GCM 主密钥
+```
+
+启动时校验 `TG_SECRET_KEY` 存在且解码后为 32 字节,否则 fail-fast。
+
+## §3 Pyrogram SQLite → gotd Session 转换
+
+**新文件:** `internal/telegram/sessionimport/pyrogram.go`
+
+```go
+package sessionimport
+
+import (
+    "crypto/sha1"
+    "database/sql"
+    "fmt"
+    _ "modernc.org/sqlite"
+
+    "github.com/gotd/td/session"
+)
+
+// Pyrogram SQLite schema (sessions 表只有一行):
+//   CREATE TABLE sessions (
+//     dc_id INTEGER PRIMARY KEY,
+//     api_id INTEGER, test_mode INTEGER,
+//     auth_key BLOB, date INTEGER NOT NULL,
+//     user_id INTEGER, is_bot INTEGER);
+func ConvertPyrogramSession(sqlitePath string) (*session.Data, error) {
+    db, err := sql.Open("sqlite", sqlitePath)
+    if err != nil { return nil, err }
+    defer db.Close()
+
+    var dcID int
+    var authKey []byte
+    err = db.QueryRow("SELECT dc_id, auth_key FROM sessions LIMIT 1").
+        Scan(&dcID, &authKey)
+    if err != nil { return nil, fmt.Errorf("read pyrogram sessions row: %w", err) }
+    if len(authKey) != 256 {
+        return nil, fmt.Errorf("invalid auth_key length: %d (expect 256)", len(authKey))
+    }
+
+    addr, err := tgDCAddr(dcID)
+    if err != nil { return nil, err }
+
+    sum := sha1.Sum(authKey)
+    keyID := sum[12:20]
+
+    return &session.Data{
+        DC:        dcID,
+        Addr:      addr,
+        AuthKey:   authKey,
+        AuthKeyID: keyID,
+    }, nil
+}
+
+// tgDCAddr maps dc_id → production IP:port.
+// Source: https://core.telegram.org/api/datacenter
+func tgDCAddr(dc int) (string, error) {
+    switch dc {
+    case 1: return "149.154.175.53:443", nil
+    case 2: return "149.154.167.51:443", nil
+    case 3: return "149.154.175.100:443", nil
+    case 4: return "149.154.167.91:443", nil
+    case 5: return "91.108.56.130:443", nil
+    }
+    return "", fmt.Errorf("unknown dc_id: %d", dc)
+}
+```
+
+**落盘:**
+```go
+storage := &session.FileStorage{Path: targetPath}
+// session.FileStorage.StoreSession 接受的是 session.Data 序列化后的 bytes;
+// 用 session.Loader{Storage: storage}.Save(ctx, data) 更稳:
+loader := session.Loader{Storage: storage}
+if err := loader.Save(ctx, data); err != nil { return err }
+```
+
+**tdata fallback:** 新文件 `internal/telegram/sessionimport/tdesktop.go`
+
+```go
+import "github.com/gotd/td/session/tdesktop"
+
+func ConvertTdataSession(tdataDir, localKey string) (*session.Data, error) {
+    // localKey:若 tdata 有密码保护,用协议号的 2fa_password.txt 内容尝试
+    // 若无密码,localKey 传 ""
+    acc, err := tdesktop.Read(tdataDir, []byte(localKey))
+    if err != nil { return nil, err }
+    return session.TDesktopSession(acc)
+}
+```
+
+**依赖新增**(`go.mod`):`modernc.org/sqlite`(纯 Go SQLite 驱动,无 CGO,Docker build 不受影响)。
+
+## §4 2FA 加密存储
+
+**新文件:** `internal/telegram/crypto.go`
+
+```go
+package telegram
+
+import (
+    "crypto/aes"
+    "crypto/cipher"
+    "crypto/rand"
+    "encoding/base64"
+    "errors"
+    "fmt"
+)
+
+type Crypto struct { key []byte }  // 32 字节
+
+func NewCrypto(b64Key string) (*Crypto, error) {
+    key, err := base64.StdEncoding.DecodeString(b64Key)
+    if err != nil { return nil, fmt.Errorf("decode secret key: %w", err) }
+    if len(key) != 32 {
+        return nil, fmt.Errorf("secret key must be 32 bytes, got %d", len(key))
+    }
+    return &Crypto{key: key}, nil
+}
+
+func (c *Crypto) Encrypt(plain string) ([]byte, error) {
+    block, _ := aes.NewCipher(c.key)
+    gcm, _ := cipher.NewGCM(block)
+    nonce := make([]byte, gcm.NonceSize())
+    if _, err := rand.Read(nonce); err != nil { return nil, err }
+    ct := gcm.Seal(nonce, nonce, []byte(plain), nil)  // [nonce || ciphertext || tag]
+    return ct, nil
+}
+
+func (c *Crypto) Decrypt(blob []byte) (string, error) {
+    block, _ := aes.NewCipher(c.key)
+    gcm, _ := cipher.NewGCM(block)
+    ns := gcm.NonceSize()
+    if len(blob) < ns { return "", errors.New("ciphertext too short") }
+    nonce, ct := blob[:ns], blob[ns:]
+    pt, err := gcm.Open(nil, nonce, ct, nil)
+    if err != nil { return "", err }
+    return string(pt), nil
+}
+```
+
+**集成:**
+- `main.go` 启动时:从 `config.Telegram.SecretKey` 构造全局 `*telegram.Crypto`,注入到 `TgAccountHandler`
+- 导入时:`twoFA` 非空则 `enc := crypto.Encrypt(twoFA)`,存 `two_fa_enc`
+- **新接口** `POST /tg-accounts/:id/reveal-2fa`(admin-only):返回明文 2FA(用于重登流程,极少用,审计日志记录)
+
+## §5 Client 集成与设备指纹
+
+**`internal/telegram/types.go` 的 `Account` 扩展**:
+
+```go
+type Account struct {
+    Phone          string
+    SessionFile    string
+    AppID          int
+    AppHash        string
+    Device         string  // NEW: "Xiaomi Mix 4"
+    AppVersion     string  // NEW: "12.0.0 (6163)"
+    SystemVersion  string  // NEW: "Android 13 (33)"(来自 DB.sdk 字段)
+    LangPack       string  // NEW: "android"
+    LangCode       string  // NEW: "en"
+    SystemLangCode string  // NEW: "en-US"
+}
+```
+
+**`internal/telegram/client.go` 的 `Connect` 改造**:
+
+```go
+opts := telegram.Options{
+    SessionStorage: storage,
+    NoUpdates:      true,
+    Device: telegram.DeviceConfig{
+        DeviceModel:    c.account.Device,
+        AppVersion:     c.account.AppVersion,
+        SystemVersion:  c.account.SystemVersion,
+        LangPack:       c.account.LangPack,
+        SystemLangCode: c.account.SystemLangCode,
+        LangCode:       c.account.LangCode,
+    },
+}
+```
+
+**字段为空时**:gotd 使用默认设备画像(老账号保持现状,不受影响)。
+
+**`reloadAccounts`**(在 `tg_account.go` handler 中):从 DB 读这些新字段填到 `telegram.Account`。
+
+## §6 前端改造与 API
+
+### 前端 `web/src/pages/TgAccounts.tsx`
+
+- 顶部两个按钮:「**导入协议号**」(主)+「手填添加」(次)
+- 点「导入协议号」弹 Modal:
+  - 单个 `<input type="file" webkitdirectory directory multiple ref={...} />`
+  - 提示文字:「选择一个协议号文件夹,如 `D:\spider\tgs\13252753163\`」
+  - 提交时:`FileList` 全部 append 到 `FormData`,字段名统一用 `files`
+- 上传中 spinner;响应含 `test_result.status` 时根据值显示 toast:`ok` 绿色、`cooling` 黄色、`fail` 红色(仍刷新列表,因为记录已入库)
+
+**列表新列:**
+- **来源** — `source` 字段:`protocol`(紫色标签)/ `manual`(灰色标签)
+- **设备** — `device` 字段,截断 + tooltip 完整显示
+- **导入状态** — `import_status`:`ok` 绿 / `session_invalid` 红 / `dead` 橙
+
+### 后端新接口
+
+```
+POST /tg-accounts/import
+  Auth: admin only (GuardedRoute 校验)
+  Content-Type: multipart/form-data
+  Body:
+    files[]: 上传目录下所有文件(浏览器会把 .session / .json / tdata/** 拍平在 FormData 里)
+
+  Response 200:
+    { account: TgAccount, test_result: { status: "ok"|"cooling"|"fail", message: string } }
+
+  Response 400: 协议号文件夹结构不完整(缺 .json 或 .session + tdata 都缺)
+  Response 409: 手机号已存在
+  Response 422: session 解析失败,body 含 { pyrogram_err, tdata_err, tmp_dir }
+```
+
+**测试复用**:现有 `POST /tg-accounts/:id/test` 保留;导入流程内部也调同一个方法。
+
+## §7 错误处理与边界情况
+
+| 场景 | 处理 |
+|---|---|
+| 测试阶段 FloodWait | 导入成功,`status="cooling"`,`enabled=true`(FloodWait 是临时的) |
+| Pyrogram SQLite 损坏 | 日志 warn,尝试 tdata;都失败 → 422,临时目录 `tmp/<uuid>/` 保留 30 分钟 |
+| `<phone>.json` 缺 `twoFA` | `two_fa_enc` 存 NULL,不报错 |
+| tdata 加密且 2fa 不匹配 | 降级为"仅 Pyrogram 可用";若 Pyrogram 也烂,整体 422 |
+| 并发导入同一手机号 | DB 唯一索引兜底;第二个请求 duplicate key → 转 409 |
+| `auth_key` 长度 ≠ 256 | 422,明确提示"协议号文件可能被篡改或损坏" |
+| 未知 `dc_id` | 422,提示"不支持的 DC,dc_id=X" |
+| `/app/sessions/` 不可写 | 500,提示运维检查挂载卷权限 |
+| FormData 无 `.session` 也无 `tdata/` | 400,"协议号目录结构不完整" |
+
+**临时目录清理**:后台 goroutine 每 10 分钟扫一次 `tmp/`,删除 mtime > 30 分钟的子目录。
+
+**审计日志**:`import`、`reveal-2fa`、`delete` 都调 `LogAudit`。
+
+## 实施顺序建议
+
+1. **`internal/telegram/crypto.go`** — AES-GCM 封装 + 单测
+2. **`internal/telegram/sessionimport/pyrogram.go`** — Pyrogram SQLite 解析 + 单测(用 tgs/ 下真实文件)
+3. **`internal/telegram/sessionimport/tdesktop.go`** — tdata fallback
+4. **`internal/model/tg_account.go`**(文件不存在,在 `user.go` 里扩展 TgAccount) — 新字段 + auto-migrate
+5. **`internal/telegram/types.go`** — `Account` 结构体扩展
+6. **`internal/telegram/client.go`** — `Connect` 传 Device
+7. **`internal/handler/tg_account.go`** — 新 `Import` handler + `RevealTwoFA` + `reloadAccounts` 同步新字段
+8. **`configs/config.yaml` + `internal/config/config.go`** — `telegram.sessions_dir` / `secret_key`
+9. **`deploy/docker-compose.yml`** — 确认 sessions 卷挂载;`.env` 示例加 `TG_SECRET_KEY`
+10. **`web/src/pages/TgAccounts.tsx`** — UI 改造
+11. **端到端测试**:用 `D:\spider\tgs\13252753163\` 跑一遍完整流程
+
+## 非目标(明确不做)
+
+- 协议号**自动扫描** `tgs/` 目录导入 — 本期手动选目录上传
+- 重登流程(session 失效后用 2FA 自动重登) — 本期只存 2FA,不实现重登
+- Pyrogram session 格式**写回** — 运行时只读原始文件,gotd 写 session 只写 `sessions/<phone>.json`
+- tdata **反向导出** — 只导入不导出
+- 批量导入(一次选多个协议号目录) — 本期一次一个