2026-04-20-tg-protocol-number-import-design.md 15 KB

TG 协议号导入设计

日期: 2026-04-20 作者: Claude (opus-4-7[1m]) + 用户 状态: 已批准,待实现

背景与目标

D:\spider\tgs\ 目录下有 5 个中文 TG 圈常见的"协议号"文件夹,每个以手机号命名,内容为:

  • <phone>.json — 包含 api_id / api_hash / phone / 设备指纹 / twoFA 密码
  • <phone>.sessionPyrogram/Telethon SQLite 格式(28672 字节),存 dc_id + 256 字节 auth_key
  • 2fa_password.txt — 2FA 明文
  • tdata/ — Telegram Desktop 客户端数据目录(备用)

当前 tg_accounts 管理后台只支持手填 session_file 路径 + app_id + app_hash,而且 gotd/tdsession.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):

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

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)
}

落盘:

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

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

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.goAccount 扩展

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.goConnect 改造

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_statusok 绿 / 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>.jsontwoFA 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 分钟的子目录。

审计日志importreveal-2fadelete 都调 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.goAccount 结构体扩展
  6. internal/telegram/client.goConnect 传 Device
  7. internal/handler/tg_account.go — 新 Import handler + RevealTwoFA + reloadAccounts 同步新字段
  8. configs/config.yaml + internal/config/config.gotelegram.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 反向导出 — 只导入不导出
  • 批量导入(一次选多个协议号目录) — 本期一次一个