# TG 协议号导入设计 **日期:** 2026-04-20 **作者:** Claude (opus-4-7[1m]) + 用户 **状态:** 已批准,待实现 ## 背景与目标 `D:\spider\tgs\` 目录下有 5 个中文 TG 圈常见的"协议号"文件夹,每个以手机号命名,内容为: - `.json` — 包含 `api_id` / `api_hash` / `phone` / 设备指纹 / `twoFA` 密码 - `.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 `` 选目录,前端拆成多文件 multipart | | Session 源 | 优先 `.session`(Pyrogram SQLite),失败则 fallback `tdata/` | | 2FA 处理 | 只存不用:AES-GCM 加密后入库,供未来重登/导出 | | 设备指纹 | 必须存并在 `Connect` 时带上,防 TG 风控踢号 | | 重复手机号 | 拒绝(409),不覆盖 | | 导入后行为 | 自动测试连接,失败标 `enabled=false` 不回滚 | ## 架构概览 **核心思路:一次性转换 + gotd 原生运行时。** ``` 前端 TgAccounts 页面 │ ├─ "导入协议号"按钮 │ 用 让用户选 D:\spider\tgs\\ 目录 │ 前端把目录下所有文件(*.json / *.session / 2fa_password.txt / tdata/**) │ 逐个 append 到 FormData 上传 │ ▼ POST /tg-accounts/import (multipart/form-data) │ ├─ Step 1. 收件:解析 multipart,按文件名/扩展名分类到临时目录 tmp// ├─ Step 2. 读 .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/.json ← 转换后的 gotd session │ sessions/.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/.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/.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/.origin/` 绝对路径,排障用 | | `import_status` | `varchar(20)` NULL | `ok` / `session_invalid` / `dead`;nullable 以兼容旧记录 | | `source` | `varchar(20)` NULL | `protocol` / `manual`,区分两种录入方式;nullable 同上 | **不改:** `session_file` 保持语义(指向 `sessions/.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: - 单个 `` - 提示文字:「选择一个协议号文件夹,如 `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[]: 上传目录下所有文件。每个文件的 webkitRelativePath(如 "13252753163/tdata/key_datas") 必须保留作为 multipart 的 filename 字段;后端据此重建 tdata/ 子目录结构,并按 文件后缀 / 名称识别 .json / .session / 2fa_password.txt / tdata/** 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//` 保留 30 分钟 | | `.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/.json` - tdata **反向导出** — 只导入不导出 - 批量导入(一次选多个协议号目录) — 本期一次一个