Bladeren bron

plan: TG protocol-number import implementation plan

14 bite-sized tasks: dc-addr table, Pyrogram SQLite + tdata conversion,
AES-GCM crypto, TgAccount schema extension, Client device fingerprint,
import/reveal-2fa handlers, webkitdirectory UI, E2E smoke test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dot 2 weken geleden
bovenliggende
commit
2d953cf1e3
1 gewijzigde bestanden met toevoegingen van 1839 en 0 verwijderingen
  1. 1839 0
      docs/superpowers/plans/2026-04-20-tg-protocol-number-import.md

+ 1839 - 0
docs/superpowers/plans/2026-04-20-tg-protocol-number-import.md

@@ -0,0 +1,1839 @@
+# TG Protocol Number Import — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Let admins upload a Chinese "协议号" folder (`<phone>.json` + `<phone>.session` Pyrogram SQLite + `tdata/` + `2fa_password.txt`) through the management UI, auto-convert it to gotd's native session format, and persist device fingerprint + encrypted 2FA so the existing TG client can use it without modification.
+
+**Architecture:** One-shot conversion at upload time. Pyrogram SQLite → extract `dc_id` + 256-byte `auth_key` → build `gotd/td` `session.Data` → write to `sessions/<phone>.json`. Fallback to `tdata/` via `gotd/td/session/tdesktop` if Pyrogram fails. Metadata (api_id, api_hash, device, 2FA) parsed from `<phone>.json` and stored in `tg_accounts` table; 2FA encrypted with AES-GCM. Runtime `telegram.Client` only reads the converted gotd JSON — unaware of Pyrogram/tdata.
+
+**Tech Stack:** Go 1.26 + `github.com/gotd/td v0.143.0` + `modernc.org/sqlite` (new, CGO-free) + Gin + GORM + MySQL + Redis; React 18 + Ant Design + axios.
+
+**Spec:** `docs/superpowers/specs/2026-04-20-tg-protocol-number-import-design.md`
+
+---
+
+## File Structure
+
+**New files:**
+- `internal/telegram/crypto.go` — AES-GCM encrypt/decrypt helper
+- `internal/telegram/crypto_test.go`
+- `internal/telegram/sessionimport/dcaddr.go` — `dc_id` → IP:port mapping
+- `internal/telegram/sessionimport/dcaddr_test.go`
+- `internal/telegram/sessionimport/pyrogram.go` — Pyrogram SQLite → `session.Data`
+- `internal/telegram/sessionimport/pyrogram_test.go`
+- `internal/telegram/sessionimport/tdesktop.go` — tdata fallback
+- `internal/telegram/sessionimport/importer.go` — orchestration, parses `<phone>.json`, writes session + backup
+- `deploy/.env.example` — `TG_SECRET_KEY` template
+
+**Modified files:**
+- `go.mod` / `go.sum` — add `modernc.org/sqlite`
+- `internal/model/user.go` — `TgAccount` new columns
+- `internal/telegram/types.go` — `Account` struct device fields
+- `internal/telegram/client.go` — `Connect` passes `telegram.DeviceConfig`
+- `internal/config/config.go` — `TelegramConfig.SessionsDir` + `SecretKey`
+- `configs/config.yaml` — two new keys
+- `internal/handler/tg_account.go` — `Import`, `RevealTwoFA`, `reloadAccounts` enriched
+- `internal/handler/router.go` — register new routes
+- `cmd/server/main.go` — construct `*telegram.Crypto`, ensure `sessions_dir` exists, inject into handler
+- `web/src/pages/TgAccounts.tsx` — Import button, upload modal, new columns
+
+**Not modified (already good):**
+- `deploy/docker-compose.yml` — `sessions/` volume already mounted at `/app/sessions`
+- `internal/telegram/account_manager.go` — no changes needed (new fields flow through `Account`)
+
+---
+
+## Task 1: Add `modernc.org/sqlite` dependency
+
+**Files:**
+- Modify: `go.mod`, `go.sum` (auto)
+
+- [ ] **Step 1: Add dependency**
+
+Run:
+```bash
+cd D:/spider && go get modernc.org/sqlite@latest
+```
+
+Expected: `go.mod` gains `modernc.org/sqlite vX.Y.Z` in the require block; `go.sum` updated.
+
+- [ ] **Step 2: Verify no CGO requirement**
+
+Run:
+```bash
+cd D:/spider && CGO_ENABLED=0 go build ./... 2>&1 | head -20
+```
+
+Expected: build succeeds (no `cgo` errors). `modernc.org/sqlite` is pure Go, must not break CGO_ENABLED=0 builds (our Docker image uses CGO_ENABLED=0).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add go.mod go.sum
+git commit -m "deps: add modernc.org/sqlite for Pyrogram session import"
+```
+
+---
+
+## Task 2: DC address mapping
+
+**Files:**
+- Create: `internal/telegram/sessionimport/dcaddr.go`
+- Test: `internal/telegram/sessionimport/dcaddr_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/telegram/sessionimport/dcaddr_test.go`:
+```go
+package sessionimport
+
+import "testing"
+
+func TestTGDCAddr(t *testing.T) {
+	cases := []struct {
+		dc   int
+		want string
+	}{
+		{1, "149.154.175.53:443"},
+		{2, "149.154.167.51:443"},
+		{3, "149.154.175.100:443"},
+		{4, "149.154.167.91:443"},
+		{5, "91.108.56.130:443"},
+	}
+	for _, tc := range cases {
+		got, err := TGDCAddr(tc.dc)
+		if err != nil {
+			t.Fatalf("dc=%d unexpected err: %v", tc.dc, err)
+		}
+		if got != tc.want {
+			t.Errorf("dc=%d got %q want %q", tc.dc, got, tc.want)
+		}
+	}
+	if _, err := TGDCAddr(99); err == nil {
+		t.Error("expected err for unknown dc=99")
+	}
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestTGDCAddr -v`
+Expected: FAIL — "undefined: TGDCAddr".
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `internal/telegram/sessionimport/dcaddr.go`:
+```go
+// Package sessionimport converts third-party Telegram session formats
+// (Pyrogram SQLite, Telegram Desktop tdata) to gotd/td's native session.Data.
+package sessionimport
+
+import "fmt"
+
+// TGDCAddr maps a Telegram data-center id to its 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("unsupported telegram dc_id: %d", dc)
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestTGDCAddr -v`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/telegram/sessionimport/dcaddr.go internal/telegram/sessionimport/dcaddr_test.go
+git commit -m "feat(sessionimport): add Telegram DC address mapping"
+```
+
+---
+
+## Task 3: Pyrogram SQLite → gotd session
+
+**Files:**
+- Create: `internal/telegram/sessionimport/pyrogram.go`
+- Test: `internal/telegram/sessionimport/pyrogram_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/telegram/sessionimport/pyrogram_test.go`:
+```go
+package sessionimport
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+// Real protocol-number session file lives at D:\spider\tgs\13252753163\13252753163.session
+// Test skips when that file is missing so CI environments don't break.
+func TestConvertPyrogramSession_RealFile(t *testing.T) {
+	path := `D:\spider\tgs\13252753163\13252753163.session`
+	if _, err := os.Stat(path); err != nil {
+		t.Skipf("real session file absent: %v", err)
+	}
+
+	data, err := ConvertPyrogramSession(path)
+	if err != nil {
+		t.Fatalf("convert failed: %v", err)
+	}
+	if data.DC < 1 || data.DC > 5 {
+		t.Errorf("unexpected dc: %d", data.DC)
+	}
+	if len(data.AuthKey) != 256 {
+		t.Errorf("auth_key length = %d, want 256", len(data.AuthKey))
+	}
+	if len(data.AuthKeyID) != 8 {
+		t.Errorf("auth_key_id length = %d, want 8", len(data.AuthKeyID))
+	}
+	if data.Addr == "" {
+		t.Error("addr empty")
+	}
+}
+
+func TestConvertPyrogramSession_MissingFile(t *testing.T) {
+	missing := filepath.Join(t.TempDir(), "does-not-exist.session")
+	if _, err := ConvertPyrogramSession(missing); err == nil {
+		t.Error("expected error for missing file")
+	}
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestConvertPyrogramSession -v`
+Expected: FAIL — "undefined: ConvertPyrogramSession".
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `internal/telegram/sessionimport/pyrogram.go`:
+```go
+package sessionimport
+
+import (
+	"crypto/sha1"
+	"database/sql"
+	"fmt"
+
+	"github.com/gotd/td/session"
+	_ "modernc.org/sqlite"
+)
+
+// ConvertPyrogramSession reads a Pyrogram/Telethon SQLite session file and
+// builds a gotd/td session.Data representing the same authorization.
+//
+// Pyrogram schema (single row in `sessions` table):
+//   CREATE TABLE sessions (
+//     dc_id     INTEGER PRIMARY KEY,
+//     api_id    INTEGER,
+//     test_mode INTEGER,
+//     auth_key  BLOB,         -- 256 bytes
+//     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, fmt.Errorf("open pyrogram sqlite: %w", 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 (want 256)", len(authKey))
+	}
+
+	addr, err := TGDCAddr(dcID)
+	if err != nil {
+		return nil, err
+	}
+
+	// auth_key_id = SHA-1(auth_key)[12:20] — lower 64 bits of the SHA-1 digest.
+	sum := sha1.Sum(authKey)
+	keyID := make([]byte, 8)
+	copy(keyID, sum[12:20])
+
+	return &session.Data{
+		DC:        dcID,
+		Addr:      addr,
+		AuthKey:   authKey,
+		AuthKeyID: keyID,
+	}, nil
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `cd D:/spider && go test ./internal/telegram/sessionimport/ -run TestConvertPyrogramSession -v`
+Expected: PASS — both the real-file test and the missing-file test pass. If the real-file test is skipped, that is acceptable; the missing-file test must pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/telegram/sessionimport/pyrogram.go internal/telegram/sessionimport/pyrogram_test.go
+git commit -m "feat(sessionimport): convert Pyrogram SQLite session to gotd session.Data"
+```
+
+---
+
+## Task 4: tdata fallback converter
+
+**Files:**
+- Create: `internal/telegram/sessionimport/tdesktop.go`
+
+- [ ] **Step 1: Write implementation**
+
+Create `internal/telegram/sessionimport/tdesktop.go`:
+```go
+package sessionimport
+
+import (
+	"fmt"
+
+	"github.com/gotd/td/session"
+	"github.com/gotd/td/session/tdesktop"
+)
+
+// ConvertTdataSession reads a Telegram Desktop tdata/ folder and builds a
+// gotd session.Data. localPassword is the local-storage password used to decrypt
+// the tdata blob; pass an empty string when the desktop client did not set a
+// local password (the common case for 协议号).
+func ConvertTdataSession(tdataDir, localPassword string) (*session.Data, error) {
+	acc, err := tdesktop.Read(tdataDir, []byte(localPassword))
+	if err != nil {
+		return nil, fmt.Errorf("read tdata: %w", err)
+	}
+	data, err := session.TDesktopSession(acc)
+	if err != nil {
+		return nil, fmt.Errorf("convert tdesktop account: %w", err)
+	}
+	return data, nil
+}
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `cd D:/spider && go build ./internal/telegram/sessionimport/`
+Expected: build succeeds. No new test file — this thin wrapper is exercised by Task 9's importer tests.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/telegram/sessionimport/tdesktop.go
+git commit -m "feat(sessionimport): add tdata fallback via gotd tdesktop reader"
+```
+
+---
+
+## Task 5: AES-GCM Crypto helper for 2FA
+
+**Files:**
+- Create: `internal/telegram/crypto.go`
+- Test: `internal/telegram/crypto_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/telegram/crypto_test.go`:
+```go
+package telegram
+
+import (
+	"encoding/base64"
+	"testing"
+)
+
+func mustKey(t *testing.T) string {
+	t.Helper()
+	// 32 zero-bytes → stable base64 for test determinism.
+	return base64.StdEncoding.EncodeToString(make([]byte, 32))
+}
+
+func TestCryptoRoundTrip(t *testing.T) {
+	c, err := NewCrypto(mustKey(t))
+	if err != nil {
+		t.Fatal(err)
+	}
+	ct, err := c.Encrypt("xing")
+	if err != nil {
+		t.Fatal(err)
+	}
+	pt, err := c.Decrypt(ct)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if pt != "xing" {
+		t.Errorf("roundtrip got %q want %q", pt, "xing")
+	}
+}
+
+func TestCryptoRejectsShortKey(t *testing.T) {
+	short := base64.StdEncoding.EncodeToString(make([]byte, 16))
+	if _, err := NewCrypto(short); err == nil {
+		t.Error("expected err for 16-byte key")
+	}
+}
+
+func TestCryptoRejectsTamperedCiphertext(t *testing.T) {
+	c, _ := NewCrypto(mustKey(t))
+	ct, _ := c.Encrypt("secret")
+	ct[len(ct)-1] ^= 0xFF
+	if _, err := c.Decrypt(ct); err == nil {
+		t.Error("expected tamper detection")
+	}
+}
+
+func TestCryptoNonceRandomness(t *testing.T) {
+	c, _ := NewCrypto(mustKey(t))
+	a, _ := c.Encrypt("same")
+	b, _ := c.Encrypt("same")
+	if string(a) == string(b) {
+		t.Error("identical ciphertexts imply nonce reuse")
+	}
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cd D:/spider && go test ./internal/telegram/ -run TestCrypto -v`
+Expected: FAIL — "undefined: NewCrypto".
+
+- [ ] **Step 3: Write implementation**
+
+Create `internal/telegram/crypto.go`:
+```go
+package telegram
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/rand"
+	"encoding/base64"
+	"errors"
+	"fmt"
+)
+
+// Crypto wraps AES-GCM with a fixed 32-byte key and random 12-byte nonces.
+// Used for encrypting 2FA passwords at rest.
+type Crypto struct {
+	gcm cipher.AEAD
+}
+
+// NewCrypto builds a Crypto from a base64-encoded 32-byte key.
+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))
+	}
+	block, err := aes.NewCipher(key)
+	if err != nil {
+		return nil, err
+	}
+	gcm, err := cipher.NewGCM(block)
+	if err != nil {
+		return nil, err
+	}
+	return &Crypto{gcm: gcm}, nil
+}
+
+// Encrypt returns [nonce || ciphertext || tag].
+func (c *Crypto) Encrypt(plain string) ([]byte, error) {
+	nonce := make([]byte, c.gcm.NonceSize())
+	if _, err := rand.Read(nonce); err != nil {
+		return nil, err
+	}
+	return c.gcm.Seal(nonce, nonce, []byte(plain), nil), nil
+}
+
+// Decrypt expects the [nonce || ciphertext || tag] layout produced by Encrypt.
+func (c *Crypto) Decrypt(blob []byte) (string, error) {
+	ns := c.gcm.NonceSize()
+	if len(blob) < ns {
+		return "", errors.New("ciphertext too short")
+	}
+	nonce, ct := blob[:ns], blob[ns:]
+	pt, err := c.gcm.Open(nil, nonce, ct, nil)
+	if err != nil {
+		return "", err
+	}
+	return string(pt), nil
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd D:/spider && go test ./internal/telegram/ -run TestCrypto -v`
+Expected: PASS on all four test cases.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/telegram/crypto.go internal/telegram/crypto_test.go
+git commit -m "feat(telegram): AES-GCM crypto helper for 2FA at-rest encryption"
+```
+
+---
+
+## Task 6: Config — `sessions_dir` + `secret_key`
+
+**Files:**
+- Modify: `internal/config/config.go`
+- Modify: `configs/config.yaml`
+- Create: `deploy/.env.example`
+
+- [ ] **Step 1: Extend `TelegramConfig`**
+
+In `internal/config/config.go`, replace the `TelegramConfig` struct:
+```go
+type TelegramConfig struct {
+	AppID       int        `mapstructure:"app_id"`
+	AppHash     string     `mapstructure:"app_hash"`
+	Accounts    []TGAccount
+	SessionsDir string     `mapstructure:"sessions_dir"` // absolute path, e.g. /app/sessions
+	SecretKey   string     `mapstructure:"secret_key"`   // base64 32-byte, or literal ${TG_SECRET_KEY}
+}
+```
+
+- [ ] **Step 2: Expand `${VAR}` from environment in `Load`**
+
+Viper does not auto-expand environment variables inside YAML. At the end of `Load`, before `global = cfg`, add:
+```go
+	// Resolve ${ENV_VAR} placeholders for secret fields.
+	cfg.Telegram.SecretKey = expandEnvPlaceholder(cfg.Telegram.SecretKey)
+	cfg.Security.JWTSecret = expandEnvPlaceholder(cfg.Security.JWTSecret)
+
+	if cfg.Telegram.SessionsDir == "" {
+		cfg.Telegram.SessionsDir = "/app/sessions"
+	}
+```
+
+Add at the bottom of the file:
+```go
+// expandEnvPlaceholder replaces a value of the form "${VAR}" with os.Getenv("VAR").
+// A non-placeholder value is returned unchanged.
+func expandEnvPlaceholder(v string) string {
+	if len(v) > 3 && v[0] == '$' && v[1] == '{' && v[len(v)-1] == '}' {
+		return os.Getenv(v[2 : len(v)-1])
+	}
+	return v
+}
+```
+
+Add `"os"` to the import block.
+
+- [ ] **Step 3: Update `configs/config.yaml`**
+
+Replace the `telegram:` block with:
+```yaml
+telegram:
+  app_id: 0
+  app_hash: ""
+  accounts: []
+  sessions_dir: "/app/sessions"
+  secret_key: "${TG_SECRET_KEY}"   # 32-byte base64 loaded from env
+```
+
+- [ ] **Step 4: Create `deploy/.env.example`**
+
+Create `deploy/.env.example`:
+```
+# 32-byte AES-GCM master key for 2FA encryption. Generate with:
+#   openssl rand -base64 32
+TG_SECRET_KEY=CHANGE_ME_TO_32_RANDOM_BASE64_BYTES_XXXXXXXXXXX=
+```
+
+- [ ] **Step 5: Verify config loads**
+
+Run:
+```bash
+cd D:/spider && TG_SECRET_KEY=$(openssl rand -base64 32) go run ./cmd/server 2>&1 | head -5
+```
+Expected: server starts (or MySQL connect error if DB not running — that is OK here). The important check is no config-parsing panic.
+
+Stop the server with Ctrl+C.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/config/config.go configs/config.yaml deploy/.env.example
+git commit -m "feat(config): add telegram.sessions_dir and telegram.secret_key with env expansion"
+```
+
+---
+
+## Task 7: Extend `TgAccount` model
+
+**Files:**
+- Modify: `internal/model/user.go`
+
+- [ ] **Step 1: Extend the struct**
+
+Replace the `TgAccount` struct in `internal/model/user.go` with:
+```go
+// TgAccount represents a Telegram account managed via the admin panel.
+type TgAccount struct {
+	ID          uint      `gorm:"primaryKey;autoIncrement" json:"id"`
+	Phone       string    `gorm:"uniqueIndex;size:50;not null" json:"phone"`
+	SessionFile string    `gorm:"size:500;not null" json:"session_file"`
+	AppID       int       `gorm:"not null" json:"app_id"`
+	AppHash     string    `gorm:"size:100;not null" json:"app_hash"`
+	Remark      string    `gorm:"size:255" json:"remark"`
+	Enabled     bool      `gorm:"default:true" json:"enabled"`
+	Status      string    `gorm:"size:20;default:'idle'" json:"status"` // idle / online / cooling
+
+	// Protocol-number import additions (all nullable to keep legacy rows valid).
+	TwoFAEnc       []byte `gorm:"type:varbinary(255)" json:"-"`                         // AES-GCM ciphertext; never emitted in JSON
+	Device         string `gorm:"size:100" json:"device"`                                // e.g. "Xiaomi Mix 4"
+	AppVersion     string `gorm:"size:50" json:"app_version"`                            // e.g. "12.0.0 (6163)"
+	SDK            string `gorm:"size:100" json:"sdk"`                                   // e.g. "Android 13 (33)"
+	LangPack       string `gorm:"size:50" json:"lang_pack"`                              // e.g. "android"
+	LangCode       string `gorm:"size:20" json:"lang_code"`                              // e.g. "en"
+	SystemLangCode string `gorm:"size:20" json:"system_lang_code"`                       // e.g. "en-US"
+	FirstName      string `gorm:"size:100" json:"first_name"`
+	LastName       string `gorm:"size:100" json:"last_name"`
+	TgUsername     string `gorm:"size:100;column:tg_username" json:"tg_username"`
+	OriginDir      string `gorm:"size:500" json:"origin_dir"`                            // sessions/<phone>.origin/ absolute path
+	ImportStatus   string `gorm:"size:20" json:"import_status"`                          // ok / session_invalid / dead
+	Source         string `gorm:"size:20" json:"source"`                                 // protocol / manual
+
+	CreatedAt time.Time `json:"created_at"`
+	UpdatedAt time.Time `json:"updated_at"`
+}
+```
+
+Note: column name `tg_username` (not `username`) to avoid colliding with the `User.Username` column concept if tables are ever joined; field name `TgUsername` distinguishes from other `Username`-ish fields in the codebase.
+
+- [ ] **Step 2: Verify auto-migrate adds columns**
+
+Run:
+```bash
+cd D:/spider && TG_SECRET_KEY=$(openssl rand -base64 32) go run ./cmd/server 2>&1 | head -20
+```
+Expected: `MySQL tables migrated` log line; no error. If MySQL is not running this step can be skipped — `go build ./...` alone is enough to confirm the model compiles.
+
+If MySQL is reachable, verify the columns exist:
+```bash
+docker exec -i im_mysql mysql -uroot -proot123 spider -e "DESCRIBE tg_accounts;" 2>&1 | grep -E 'two_fa_enc|device|app_version|sdk|lang_pack|origin_dir|import_status|source'
+```
+Expected: all new columns listed.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/model/user.go
+git commit -m "feat(model): add protocol-number fields to TgAccount (device, 2fa, origin_dir...)"
+```
+
+---
+
+## Task 8: Extend `telegram.Account` + `Client.Connect` device fingerprint
+
+**Files:**
+- Modify: `internal/telegram/types.go`
+- Modify: `internal/telegram/client.go`
+
+- [ ] **Step 1: Extend `Account` struct**
+
+Replace the `Account` struct in `internal/telegram/types.go`:
+```go
+// Account TG 账号信息
+type Account struct {
+	Phone          string
+	SessionFile    string
+	AppID          int
+	AppHash        string
+	Device         string // DeviceModel  e.g. "Xiaomi Mix 4" — empty = gotd default
+	AppVersion     string // e.g. "12.0.0 (6163)"
+	SystemVersion  string // e.g. "Android 13 (33)" — mapped from DB.sdk
+	LangPack       string // e.g. "android"
+	LangCode       string // e.g. "en"
+	SystemLangCode string // e.g. "en-US"
+}
+```
+
+- [ ] **Step 2: Pass device config into gotd**
+
+In `internal/telegram/client.go`, inside `Connect`, change the `opts := telegram.Options{...}` block from:
+```go
+	opts := telegram.Options{
+		SessionStorage: storage,
+		NoUpdates:      true,
+	}
+```
+to:
+```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,
+		},
+	}
+```
+
+Per gotd docs, empty fields inside `DeviceConfig` fall back to gotd's default values — so legacy accounts (no device info stored) behave exactly as before.
+
+- [ ] **Step 3: Verify build**
+
+Run: `cd D:/spider && go build ./...`
+Expected: succeeds.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/telegram/types.go internal/telegram/client.go
+git commit -m "feat(telegram): thread device fingerprint into Client.Connect InitConnection"
+```
+
+---
+
+## Task 9: Importer orchestration
+
+**Files:**
+- Create: `internal/telegram/sessionimport/importer.go`
+
+- [ ] **Step 1: Write implementation**
+
+Create `internal/telegram/sessionimport/importer.go`:
+```go
+package sessionimport
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+
+	"github.com/gotd/td/session"
+)
+
+// PyrogramMeta is the parsed content of <phone>.json inside a 协议号 folder.
+// Unknown fields are ignored.
+type PyrogramMeta struct {
+	Phone          string `json:"phone"`
+	APIID          int    `json:"api_id"`
+	APIHash        string `json:"api_hash"`
+	AppID          int    `json:"app_id"`   // some generators use app_id/app_hash instead
+	AppHash        string `json:"app_hash"`
+	Device         string `json:"device"`
+	AppVersion     string `json:"app_version"`
+	SDK            string `json:"sdk"`
+	LangPack       string `json:"lang_pack"`
+	LangCode       string `json:"lang_code"`
+	SystemLangCode string `json:"system_lang_code"`
+	FirstName      string `json:"first_name"`
+	LastName       string `json:"last_name"`
+	Username       string `json:"username"`
+	TwoFA          string `json:"twoFA"`
+}
+
+// Normalize resolves API credential aliasing. 协议号 JSON files use api_id/api_hash
+// or app_id/app_hash interchangeably; we coalesce them.
+func (m *PyrogramMeta) Normalize() {
+	if m.APIID == 0 && m.AppID != 0 {
+		m.APIID = m.AppID
+	}
+	if m.APIHash == "" && m.AppHash != "" {
+		m.APIHash = m.AppHash
+	}
+}
+
+// Result is the outcome of importing one protocol-number folder.
+type Result struct {
+	Meta           PyrogramMeta
+	TwoFAPlaintext string      // from 2fa_password.txt if Meta.TwoFA empty
+	SessionData    *session.Data
+	UsedSource     string      // "pyrogram" or "tdata"
+	SessionFile    string      // sessions/<phone>.json
+	OriginDir      string      // sessions/<phone>.origin/
+	PyrogramErr    string      // empty on success
+	TdataErr       string      // empty on success or if tdata absent
+}
+
+// StagedFile is one file extracted from a multipart upload, already written to a
+// temp directory. RelPath preserves the browser's webkitRelativePath minus the
+// top-level folder name (so e.g. "13252753163/tdata/key_datas" becomes
+// "tdata/key_datas").
+type StagedFile struct {
+	RelPath string
+	AbsPath string
+}
+
+// Import parses staged files from a 协议号 folder, converts the session, and
+// materializes sessions/<phone>.json + origin/ backup under sessionsDir.
+// The caller (HTTP handler) persists the returned metadata to the DB.
+//
+// Precondition: all StagedFile.AbsPath must exist on disk. stagedFiles is the
+// output of the handler's multipart parser.
+func Import(ctx context.Context, sessionsDir string, stagedFiles []StagedFile) (*Result, error) {
+	if len(stagedFiles) == 0 {
+		return nil, errors.New("no files uploaded")
+	}
+
+	var metaPath, sessionPath, twoFAPath, tdataDir string
+	for _, f := range stagedFiles {
+		switch {
+		case filepath.Ext(f.RelPath) == ".json" && !hasSep(f.RelPath):
+			metaPath = f.AbsPath
+		case filepath.Ext(f.RelPath) == ".session":
+			sessionPath = f.AbsPath
+		case filepath.Base(f.RelPath) == "2fa_password.txt":
+			twoFAPath = f.AbsPath
+		case hasTdataPrefix(f.RelPath):
+			// Remember the parent dir containing tdata/
+			dir := filepath.Dir(f.AbsPath)
+			// Walk up until we find a folder literally named "tdata".
+			for dir != "" && filepath.Base(dir) != "tdata" {
+				dir = filepath.Dir(dir)
+			}
+			if dir != "" {
+				tdataDir = dir
+			}
+		}
+	}
+
+	if metaPath == "" {
+		return nil, errors.New("missing <phone>.json in upload")
+	}
+	if sessionPath == "" && tdataDir == "" {
+		return nil, errors.New("upload has neither .session nor tdata/")
+	}
+
+	meta, err := readMeta(metaPath)
+	if err != nil {
+		return nil, err
+	}
+	meta.Normalize()
+	if meta.Phone == "" {
+		return nil, errors.New("<phone>.json missing phone field")
+	}
+	if meta.APIID == 0 || meta.APIHash == "" {
+		return nil, errors.New("<phone>.json missing api_id / api_hash")
+	}
+
+	res := &Result{Meta: meta}
+
+	// Prefer 2FA from JSON; fall back to 2fa_password.txt.
+	if meta.TwoFA == "" && twoFAPath != "" {
+		b, err := os.ReadFile(twoFAPath)
+		if err == nil {
+			res.TwoFAPlaintext = sanitizeTwoFA(string(b))
+		}
+	} else {
+		res.TwoFAPlaintext = meta.TwoFA
+	}
+
+	// Try Pyrogram first.
+	if sessionPath != "" {
+		data, err := ConvertPyrogramSession(sessionPath)
+		if err == nil {
+			res.SessionData = data
+			res.UsedSource = "pyrogram"
+		} else {
+			res.PyrogramErr = err.Error()
+		}
+	}
+
+	// Fallback to tdata.
+	if res.SessionData == nil && tdataDir != "" {
+		data, err := ConvertTdataSession(tdataDir, res.TwoFAPlaintext)
+		if err == nil {
+			res.SessionData = data
+			res.UsedSource = "tdata"
+		} else {
+			res.TdataErr = err.Error()
+		}
+	}
+
+	if res.SessionData == nil {
+		return res, fmt.Errorf("session conversion failed (pyrogram: %s; tdata: %s)",
+			nonempty(res.PyrogramErr, "n/a"), nonempty(res.TdataErr, "n/a"))
+	}
+
+	// Materialize session JSON.
+	if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
+		return res, fmt.Errorf("create sessions dir: %w", err)
+	}
+	res.SessionFile = filepath.Join(sessionsDir, meta.Phone+".json")
+	storage := &session.FileStorage{Path: res.SessionFile}
+	loader := session.Loader{Storage: storage}
+	if err := loader.Save(ctx, res.SessionData); err != nil {
+		return res, fmt.Errorf("save gotd session: %w", err)
+	}
+
+	// Materialize origin backup (best-effort; non-fatal).
+	res.OriginDir = filepath.Join(sessionsDir, meta.Phone+".origin")
+	_ = os.RemoveAll(res.OriginDir)
+	if err := os.MkdirAll(res.OriginDir, 0o755); err == nil {
+		for _, f := range stagedFiles {
+			dst := filepath.Join(res.OriginDir, f.RelPath)
+			if err := os.MkdirAll(filepath.Dir(dst), 0o755); err == nil {
+				_ = copyFile(f.AbsPath, dst)
+			}
+		}
+	}
+
+	return res, nil
+}
+
+func readMeta(path string) (PyrogramMeta, error) {
+	var m PyrogramMeta
+	b, err := os.ReadFile(path)
+	if err != nil {
+		return m, fmt.Errorf("read meta json: %w", err)
+	}
+	if err := json.Unmarshal(b, &m); err != nil {
+		return m, fmt.Errorf("parse meta json: %w", err)
+	}
+	return m, nil
+}
+
+func sanitizeTwoFA(s string) string {
+	// Trim CR/LF/space — 2fa_password.txt often has a trailing newline.
+	for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ') {
+		s = s[:len(s)-1]
+	}
+	return s
+}
+
+func hasSep(p string) bool {
+	for i := 0; i < len(p); i++ {
+		if p[i] == '/' || p[i] == '\\' {
+			return true
+		}
+	}
+	return false
+}
+
+func hasTdataPrefix(p string) bool {
+	// "tdata/..." or "tdata\..."
+	if len(p) < 6 {
+		return false
+	}
+	return (p[:5] == "tdata" && (p[5] == '/' || p[5] == '\\'))
+}
+
+func nonempty(s, alt string) string {
+	if s == "" {
+		return alt
+	}
+	return s
+}
+
+func copyFile(src, dst string) error {
+	in, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+	out, err := os.Create(dst)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+	_, err = io.Copy(out, in)
+	return err
+}
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `cd D:/spider && go build ./internal/telegram/sessionimport/`
+Expected: succeeds.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/telegram/sessionimport/importer.go
+git commit -m "feat(sessionimport): importer orchestration (pyrogram→tdata fallback + backup)"
+```
+
+---
+
+## Task 10: Import HTTP handler
+
+**Files:**
+- Modify: `internal/handler/tg_account.go`
+
+- [ ] **Step 1: Add dependencies & fields**
+
+In `internal/handler/tg_account.go`, replace the existing imports + struct with:
+```go
+package handler
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"mime/multipart"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"spider/internal/model"
+	"spider/internal/store"
+	"spider/internal/telegram"
+	"spider/internal/telegram/sessionimport"
+
+	"github.com/gin-gonic/gin"
+	"github.com/google/uuid"
+)
+
+// TgAccountHandler handles Telegram account management.
+type TgAccountHandler struct {
+	store       *store.Store
+	tgManager   *telegram.AccountManager
+	crypto      *telegram.Crypto
+	sessionsDir string
+	tmpDir      string // <sessionsDir>/.tmp — staging area for multipart uploads
+}
+```
+
+`github.com/google/uuid` is already an indirect dependency (from gotd/td). If `go build` later complains, promote it with `go get github.com/google/uuid`.
+
+- [ ] **Step 2: Add a constructor**
+
+Append this constructor to `internal/handler/tg_account.go`:
+```go
+// NewTgAccountHandler builds the handler with dependencies injected from main.
+func NewTgAccountHandler(s *store.Store, tgMgr *telegram.AccountManager, crypto *telegram.Crypto, sessionsDir string) *TgAccountHandler {
+	tmp := filepath.Join(sessionsDir, ".tmp")
+	_ = os.MkdirAll(tmp, 0o755)
+	h := &TgAccountHandler{
+		store:       s,
+		tgManager:   tgMgr,
+		crypto:      crypto,
+		sessionsDir: sessionsDir,
+		tmpDir:      tmp,
+	}
+	go h.tmpCleanupLoop()
+	return h
+}
+
+// tmpCleanupLoop deletes tmp subdirs older than 30 minutes every 10 minutes.
+func (h *TgAccountHandler) tmpCleanupLoop() {
+	t := time.NewTicker(10 * time.Minute)
+	defer t.Stop()
+	for range t.C {
+		entries, err := os.ReadDir(h.tmpDir)
+		if err != nil {
+			continue
+		}
+		cutoff := time.Now().Add(-30 * time.Minute)
+		for _, e := range entries {
+			if !e.IsDir() {
+				continue
+			}
+			info, err := e.Info()
+			if err != nil || info.ModTime().After(cutoff) {
+				continue
+			}
+			_ = os.RemoveAll(filepath.Join(h.tmpDir, e.Name()))
+		}
+	}
+}
+```
+
+- [ ] **Step 3: Add the Import method**
+
+Append to `internal/handler/tg_account.go`:
+```go
+// Import handles POST /tg-accounts/import — admin uploads a 协议号 folder.
+func (h *TgAccountHandler) Import(c *gin.Context) {
+	form, err := c.MultipartForm()
+	if err != nil {
+		Fail(c, 400, "invalid multipart form: "+err.Error())
+		return
+	}
+
+	files := form.File["files"]
+	if len(files) == 0 {
+		Fail(c, 400, "no files uploaded (expected field name: files)")
+		return
+	}
+
+	// Stage uploaded files to tmp/<uuid>/.
+	stageID := uuid.NewString()
+	stageDir := filepath.Join(h.tmpDir, stageID)
+	if err := os.MkdirAll(stageDir, 0o755); err != nil {
+		Fail(c, 500, "create stage dir: "+err.Error())
+		return
+	}
+
+	// Top-level folder name in webkitRelativePath (e.g. "13252753163"). Used to
+	// strip the prefix so RelPath inside the Result is relative to the folder.
+	topPrefix := detectTopPrefix(files)
+
+	staged := make([]sessionimport.StagedFile, 0, len(files))
+	for _, fh := range files {
+		// Gin stores the relative path in fh.Filename (browsers send it as the
+		// form part filename when webkitdirectory is used).
+		rel := normalizeRel(fh.Filename, topPrefix)
+		if rel == "" {
+			continue
+		}
+		abs := filepath.Join(stageDir, rel)
+		if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
+			Fail(c, 500, "mkdir stage: "+err.Error())
+			return
+		}
+		if err := saveUpload(fh, abs); err != nil {
+			Fail(c, 500, "save upload: "+err.Error())
+			return
+		}
+		staged = append(staged, sessionimport.StagedFile{RelPath: rel, AbsPath: abs})
+	}
+
+	ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
+	defer cancel()
+
+	res, err := sessionimport.Import(ctx, h.sessionsDir, staged)
+	if err != nil {
+		Fail(c, 422, err.Error())
+		return
+	}
+
+	// Duplicate check.
+	var count int64
+	h.store.DB.Model(&model.TgAccount{}).Where("phone = ?", res.Meta.Phone).Count(&count)
+	if count > 0 {
+		Fail(c, 409, "该手机号已存在")
+		return
+	}
+
+	// Encrypt 2FA if present.
+	var twoFAEnc []byte
+	if res.TwoFAPlaintext != "" && h.crypto != nil {
+		twoFAEnc, err = h.crypto.Encrypt(res.TwoFAPlaintext)
+		if err != nil {
+			Fail(c, 500, "encrypt 2fa: "+err.Error())
+			return
+		}
+	}
+
+	acc := model.TgAccount{
+		Phone:          res.Meta.Phone,
+		SessionFile:    res.SessionFile,
+		AppID:          res.Meta.APIID,
+		AppHash:        res.Meta.APIHash,
+		Enabled:        true,
+		Status:         "idle",
+		TwoFAEnc:       twoFAEnc,
+		Device:         res.Meta.Device,
+		AppVersion:     res.Meta.AppVersion,
+		SDK:            res.Meta.SDK,
+		LangPack:       res.Meta.LangPack,
+		LangCode:       res.Meta.LangCode,
+		SystemLangCode: res.Meta.SystemLangCode,
+		FirstName:      res.Meta.FirstName,
+		LastName:       res.Meta.LastName,
+		TgUsername:     res.Meta.Username,
+		OriginDir:      res.OriginDir,
+		ImportStatus:   "ok",
+		Source:         "protocol",
+	}
+	if err := h.store.DB.Create(&acc).Error; err != nil {
+		if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
+			Fail(c, 409, "该手机号已存在")
+			return
+		}
+		Fail(c, 500, "db create: "+err.Error())
+		return
+	}
+
+	// Rebuild account manager so new account is Acquireable.
+	h.reloadAccounts()
+
+	// Auto-test.
+	testResult := h.runTest(c.Request.Context(), &acc)
+	if testResult["status"] == "fail" {
+		h.store.DB.Model(&acc).Updates(map[string]any{
+			"enabled":       false,
+			"import_status": "dead",
+		})
+	}
+
+	LogAudit(h.store, c, "import", "tg_account", fmt.Sprintf("%d", acc.ID), gin.H{
+		"phone":  acc.Phone,
+		"source": res.UsedSource,
+	})
+
+	// Reload original so JSON response reflects updates.
+	h.store.DB.First(&acc, acc.ID)
+
+	// Cleanup stage dir on success.
+	_ = os.RemoveAll(stageDir)
+
+	OK(c, gin.H{"account": acc, "test_result": testResult})
+}
+
+// runTest calls Client.Connect + waits for ready — no GetMe, kept minimal.
+func (h *TgAccountHandler) runTest(ctx context.Context, acc *model.TgAccount) gin.H {
+	client := telegram.New(telegram.Account{
+		Phone:          acc.Phone,
+		SessionFile:    acc.SessionFile,
+		AppID:          acc.AppID,
+		AppHash:        acc.AppHash,
+		Device:         acc.Device,
+		AppVersion:     acc.AppVersion,
+		SystemVersion:  acc.SDK,
+		LangPack:       acc.LangPack,
+		LangCode:       acc.LangCode,
+		SystemLangCode: acc.SystemLangCode,
+	})
+	tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+	defer cancel()
+	if err := client.Connect(tctx); err != nil {
+		if fwe, ok := err.(*telegram.FloodWaitError); ok {
+			return gin.H{"status": "cooling", "message": fmt.Sprintf("FloodWait: 需等待 %d 秒", fwe.Seconds)}
+		}
+		return gin.H{"status": "fail", "error": err.Error()}
+	}
+	client.Disconnect()
+	return gin.H{"status": "ok", "message": "连接成功"}
+}
+
+func detectTopPrefix(files []*multipart.FileHeader) string {
+	if len(files) == 0 {
+		return ""
+	}
+	first := files[0].Filename
+	idx := strings.IndexAny(first, "/\\")
+	if idx <= 0 {
+		return ""
+	}
+	return first[:idx]
+}
+
+func normalizeRel(filename, topPrefix string) string {
+	// Convert backslashes to forward slashes for consistent path handling.
+	p := strings.ReplaceAll(filename, "\\", "/")
+	// Strip the top-level directory name so e.g. "13252753163/tdata/key_datas"
+	// becomes "tdata/key_datas".
+	if topPrefix != "" && strings.HasPrefix(p, topPrefix+"/") {
+		p = strings.TrimPrefix(p, topPrefix+"/")
+	}
+	// Reject any path that tries to escape.
+	if strings.Contains(p, "../") || strings.HasPrefix(p, "/") {
+		return ""
+	}
+	return p
+}
+
+func saveUpload(fh *multipart.FileHeader, dst string) error {
+	src, err := fh.Open()
+	if err != nil {
+		return err
+	}
+	defer src.Close()
+	out, err := os.Create(dst)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+	_, err = io.Copy(out, src)
+	return err
+}
+```
+
+- [ ] **Step 4: Update `reloadAccounts` to populate new fields**
+
+Replace the existing `reloadAccounts` method body in `internal/handler/tg_account.go` with:
+```go
+// reloadAccounts loads enabled TG accounts from DB and reinitializes the account manager.
+func (h *TgAccountHandler) reloadAccounts() {
+	var dbAccounts []model.TgAccount
+	h.store.DB.Where("enabled = ?", true).Find(&dbAccounts)
+
+	accounts := make([]telegram.Account, 0, len(dbAccounts))
+	for _, a := range dbAccounts {
+		accounts = append(accounts, telegram.Account{
+			Phone:          a.Phone,
+			SessionFile:    a.SessionFile,
+			AppID:          a.AppID,
+			AppHash:        a.AppHash,
+			Device:         a.Device,
+			AppVersion:     a.AppVersion,
+			SystemVersion:  a.SDK,
+			LangPack:       a.LangPack,
+			LangCode:       a.LangCode,
+			SystemLangCode: a.SystemLangCode,
+		})
+	}
+	h.tgManager.Init(accounts)
+}
+```
+
+- [ ] **Step 5: Verify build**
+
+Run: `cd D:/spider && go build ./...`
+Expected: succeeds. If `uuid` import fails, run `go get github.com/google/uuid` then retry.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/handler/tg_account.go
+git commit -m "feat(handler): TG protocol-number import endpoint with device/2FA fields"
+```
+
+---
+
+## Task 11: RevealTwoFA handler
+
+**Files:**
+- Modify: `internal/handler/tg_account.go`
+
+- [ ] **Step 1: Append RevealTwoFA method**
+
+Append to `internal/handler/tg_account.go`:
+```go
+// RevealTwoFA returns the decrypted 2FA password for an account.
+// Admin-only; every call is audit-logged.
+func (h *TgAccountHandler) RevealTwoFA(c *gin.Context) {
+	id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil {
+		Fail(c, 400, "invalid id")
+		return
+	}
+	var acc model.TgAccount
+	if err := h.store.DB.First(&acc, id).Error; err != nil {
+		Fail(c, 404, "账号不存在")
+		return
+	}
+	if len(acc.TwoFAEnc) == 0 {
+		Fail(c, 404, "该账号未存储 2FA")
+		return
+	}
+	if h.crypto == nil {
+		Fail(c, 500, "crypto not configured")
+		return
+	}
+	plain, err := h.crypto.Decrypt(acc.TwoFAEnc)
+	if err != nil {
+		Fail(c, 500, "decrypt: "+err.Error())
+		return
+	}
+	LogAudit(h.store, c, "reveal_2fa", "tg_account", fmt.Sprintf("%d", id), nil)
+	OK(c, gin.H{"password": plain})
+}
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `cd D:/spider && go build ./...`
+Expected: succeeds.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/handler/tg_account.go
+git commit -m "feat(handler): POST /tg-accounts/:id/reveal-2fa with audit logging"
+```
+
+---
+
+## Task 12: Wire Crypto + routes + main.go
+
+**Files:**
+- Modify: `cmd/server/main.go`
+- Modify: `internal/handler/router.go`
+
+- [ ] **Step 1: Update `SetupRouter` signature**
+
+In `internal/handler/router.go`, change the signature of `SetupRouter` to accept the handler instance rather than building it inline. Replace the existing line:
+```go
+func SetupRouter(s *store.Store, taskMgr *task.Manager, rdb *redis.Client, tgMgr *telegram.AccountManager) *gin.Engine {
+```
+with:
+```go
+func SetupRouter(s *store.Store, taskMgr *task.Manager, rdb *redis.Client, tgMgr *telegram.AccountManager, tgAccountHandler *TgAccountHandler) *gin.Engine {
+```
+
+Then inside `SetupRouter`, replace the block:
+```go
+	// TG account management
+	ta := &TgAccountHandler{store: s, tgManager: tgMgr}
+	protected.GET("/tg-accounts", ta.List) // all logged-in users can view
+	adminOnly.POST("/tg-accounts", ta.Create)
+	adminOnly.PUT("/tg-accounts/:id", ta.Update)
+	adminOnly.POST("/tg-accounts/:id/test", ta.Test)
+	adminOnly.DELETE("/tg-accounts/:id", ta.Delete)
+```
+with:
+```go
+	// TG account management
+	ta := tgAccountHandler
+	protected.GET("/tg-accounts", ta.List) // all logged-in users can view
+	adminOnly.POST("/tg-accounts", ta.Create)
+	adminOnly.POST("/tg-accounts/import", ta.Import)
+	adminOnly.PUT("/tg-accounts/:id", ta.Update)
+	adminOnly.POST("/tg-accounts/:id/test", ta.Test)
+	adminOnly.POST("/tg-accounts/:id/reveal-2fa", ta.RevealTwoFA)
+	adminOnly.DELETE("/tg-accounts/:id", ta.Delete)
+```
+
+- [ ] **Step 2: Update main.go to construct and pass the handler**
+
+In `cmd/server/main.go`, after step 8 ("Initialize external clients") and before step 9 ("Load TG accounts from DB"), insert:
+```go
+	// 8b. Construct TG crypto helper (required, fails fast on missing/invalid key).
+	tgCrypto, err := telegram.NewCrypto(cfg.Telegram.SecretKey)
+	if err != nil {
+		log.Fatalf("TG_SECRET_KEY invalid: %v — set a 32-byte base64 value in env", err)
+	}
+	sessionsDir := cfg.Telegram.SessionsDir
+	if sessionsDir == "" {
+		sessionsDir = "/app/sessions"
+	}
+	if err := os.MkdirAll(sessionsDir, 0o755); err != nil {
+		log.Fatalf("create sessions dir %s: %v", sessionsDir, err)
+	}
+```
+
+Then replace the existing line:
+```go
+	r := handler.SetupRouter(s, taskMgr, rdb, tgManager)
+```
+with:
+```go
+	tgAccountHandler := handler.NewTgAccountHandler(s, tgManager, tgCrypto, sessionsDir)
+	r := handler.SetupRouter(s, taskMgr, rdb, tgManager, tgAccountHandler)
+```
+
+- [ ] **Step 3: Verify build and missing-key fail-fast**
+
+Run: `cd D:/spider && go build ./...`
+Expected: succeeds.
+
+Run without the env var set:
+```bash
+cd D:/spider && unset TG_SECRET_KEY && go run ./cmd/server 2>&1 | head -3
+```
+Expected: process exits with log line `TG_SECRET_KEY invalid: secret key must be 32 bytes...`.
+
+Run with a valid key:
+```bash
+cd D:/spider && TG_SECRET_KEY=$(openssl rand -base64 32) go run ./cmd/server 2>&1 | head -10
+```
+Expected: `MySQL tables migrated` + `Server starting on :8080` (or a DB/Redis connection error, which is fine here — we only need to confirm config + route wiring didn't crash). Stop with Ctrl+C.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cmd/server/main.go internal/handler/router.go
+git commit -m "feat: wire TG crypto + import/reveal-2fa routes into server boot"
+```
+
+---
+
+## Task 13: Frontend — TgAccounts import UI
+
+**Files:**
+- Modify: `web/src/pages/TgAccounts.tsx`
+
+- [ ] **Step 1: Replace the entire file**
+
+Overwrite `web/src/pages/TgAccounts.tsx` with:
+```tsx
+import { useEffect, useState, useCallback, useRef } from 'react'
+import { Table, Tag, Button, Modal, Form, Input, InputNumber, Switch, Space, message, Popconfirm, Tooltip } from 'antd'
+import { PlusOutlined, DeleteOutlined, CheckCircleOutlined, CloudUploadOutlined, EyeOutlined } from '@ant-design/icons'
+import api from '../api/client'
+
+interface TgAccount {
+  id: number
+  phone: string
+  session_file: string
+  app_id: number
+  app_hash: string
+  remark: string
+  enabled: boolean
+  status: string
+  device?: string
+  app_version?: string
+  sdk?: string
+  import_status?: string
+  source?: string
+  first_name?: string
+  last_name?: string
+  tg_username?: string
+  created_at: string
+}
+
+const statusColors: Record<string, string> = {
+  idle: 'default',
+  online: 'green',
+  cooling: 'orange',
+  dead: 'red',
+}
+
+const sourceColors: Record<string, string> = {
+  protocol: 'purple',
+  manual: 'default',
+}
+
+const importStatusColors: Record<string, string> = {
+  ok: 'green',
+  session_invalid: 'red',
+  dead: 'orange',
+}
+
+export default function TgAccounts() {
+  const [accounts, setAccounts] = useState<TgAccount[]>([])
+  const [loading, setLoading] = useState(false)
+  const [manualOpen, setManualOpen] = useState(false)
+  const [importOpen, setImportOpen] = useState(false)
+  const [importing, setImporting] = useState(false)
+  const [form] = Form.useForm()
+  const dirInputRef = useRef<HTMLInputElement | null>(null)
+
+  const fetchAccounts = useCallback(async () => {
+    setLoading(true)
+    try {
+      const res = await api.get('/tg-accounts')
+      setAccounts(res.data || [])
+    } catch { message.error('获取TG账号列表失败') }
+    finally { setLoading(false) }
+  }, [])
+
+  useEffect(() => { fetchAccounts() }, [fetchAccounts])
+
+  const handleManualCreate = async () => {
+    try {
+      const values = await form.validateFields()
+      await api.post('/tg-accounts', values)
+      message.success('TG账号已添加')
+      setManualOpen(false)
+      fetchAccounts()
+    } catch (err: any) {
+      if (err?.errorFields) return
+      message.error(err?.response?.data?.message || '添加失败')
+    }
+  }
+
+  const handleImport = async () => {
+    const files = dirInputRef.current?.files
+    if (!files || files.length === 0) {
+      message.warning('请先选择协议号文件夹')
+      return
+    }
+    const fd = new FormData()
+    for (let i = 0; i < files.length; i++) {
+      const f = files[i]
+      // The third arg preserves webkitRelativePath as the multipart filename.
+      fd.append('files', f, (f as any).webkitRelativePath || f.name)
+    }
+    setImporting(true)
+    try {
+      const res = await api.post('/tg-accounts/import', fd, {
+        headers: { 'Content-Type': 'multipart/form-data' },
+      })
+      const tr = res.data?.test_result
+      if (tr?.status === 'ok') message.success('导入成功并测试通过')
+      else if (tr?.status === 'cooling') message.warning(`导入成功,但账号冷却中:${tr.message}`)
+      else message.error(`导入成功但连接失败:${tr?.error || '未知错误'}(账号已禁用)`)
+      setImportOpen(false)
+      fetchAccounts()
+    } catch (err: any) {
+      message.error(err?.response?.data?.message || '导入失败')
+    } finally {
+      setImporting(false)
+    }
+  }
+
+  const handleToggle = async (acc: TgAccount, enabled: boolean) => {
+    await api.put(`/tg-accounts/${acc.id}`, { enabled })
+    message.success('状态已更新')
+    fetchAccounts()
+  }
+
+  const [testingId, setTestingId] = useState<number | null>(null)
+
+  const handleTest = async (id: number) => {
+    setTestingId(id)
+    try {
+      const res = await api.post(`/tg-accounts/${id}/test`)
+      const data = res.data as { status: string; message?: string; error?: string }
+      if (data.status === 'ok') message.success(data.message || '连接成功')
+      else if (data.status === 'cooling') message.warning(data.message || 'FloodWait 冷却中')
+      else message.error(`连接失败: ${data.error || '未知错误'}`)
+    } catch {
+      message.error('测试请求失败')
+    }
+    setTestingId(null)
+  }
+
+  const handleReveal2FA = async (id: number) => {
+    try {
+      const res = await api.post(`/tg-accounts/${id}/reveal-2fa`)
+      Modal.info({
+        title: '2FA 密码',
+        content: res.data?.password ?? '(空)',
+      })
+    } catch (err: any) {
+      message.error(err?.response?.data?.message || '无法获取 2FA')
+    }
+  }
+
+  const handleDelete = async (id: number) => {
+    await api.delete(`/tg-accounts/${id}`)
+    message.success('已删除')
+    fetchAccounts()
+  }
+
+  const columns = [
+    { title: 'ID', dataIndex: 'id', width: 60 },
+    { title: '手机号', dataIndex: 'phone' },
+    {
+      title: '来源',
+      dataIndex: 'source',
+      width: 80,
+      render: (v: string) => v ? <Tag color={sourceColors[v] ?? 'default'}>{v === 'protocol' ? '协议号' : '手填'}</Tag> : '-',
+    },
+    {
+      title: '设备',
+      dataIndex: 'device',
+      width: 160,
+      ellipsis: true,
+      render: (v: string) => v ? <Tooltip title={v}>{v}</Tooltip> : '-',
+    },
+    {
+      title: '状态',
+      dataIndex: 'status',
+      render: (v: string) => <Tag color={statusColors[v] ?? 'default'}>{v}</Tag>,
+    },
+    {
+      title: '导入状态',
+      dataIndex: 'import_status',
+      render: (v: string) => v ? <Tag color={importStatusColors[v] ?? 'default'}>{v}</Tag> : '-',
+    },
+    { title: '备注', dataIndex: 'remark', render: (v: string) => v || '-' },
+    {
+      title: '启用',
+      dataIndex: 'enabled',
+      render: (v: boolean, record: TgAccount) => (
+        <Switch checked={v} onChange={(c) => handleToggle(record, c)} checkedChildren="启用" unCheckedChildren="禁用" />
+      ),
+    },
+    {
+      title: '操作',
+      key: 'action',
+      width: 260,
+      render: (_: unknown, record: TgAccount) => (
+        <Space>
+          <Button
+            size="small"
+            icon={<CheckCircleOutlined />}
+            loading={testingId === record.id}
+            onClick={() => handleTest(record.id)}
+          >
+            测试
+          </Button>
+          <Button size="small" icon={<EyeOutlined />} onClick={() => handleReveal2FA(record.id)}>2FA</Button>
+          <Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
+            <Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
+          </Popconfirm>
+        </Space>
+      ),
+    },
+  ]
+
+  return (
+    <div>
+      <Space style={{ marginBottom: 16 }}>
+        <Button
+          type="primary"
+          icon={<CloudUploadOutlined />}
+          onClick={() => setImportOpen(true)}
+        >
+          导入协议号
+        </Button>
+        <Button
+          icon={<PlusOutlined />}
+          onClick={() => { form.resetFields(); setManualOpen(true) }}
+        >
+          手填添加
+        </Button>
+      </Space>
+
+      <Table dataSource={accounts} columns={columns} rowKey="id" loading={loading} pagination={false} />
+
+      <Modal
+        title="导入协议号"
+        open={importOpen}
+        onOk={handleImport}
+        confirmLoading={importing}
+        onCancel={() => setImportOpen(false)}
+        okText="导入"
+        width={500}
+      >
+        <div style={{ marginTop: 16 }}>
+          <p>选择一个协议号目录(如 <code>D:\spider\tgs\13252753163\</code>),目录下应包含 <code>&lt;phone&gt;.json</code>、<code>&lt;phone&gt;.session</code>、<code>2fa_password.txt</code> 和(可选) <code>tdata/</code>。</p>
+          <input
+            ref={dirInputRef}
+            type="file"
+            multiple
+            /* @ts-expect-error non-standard HTML5 folder picker */
+            webkitdirectory=""
+            directory=""
+          />
+        </div>
+      </Modal>
+
+      <Modal
+        title="手填 TG 账号"
+        open={manualOpen}
+        onOk={handleManualCreate}
+        onCancel={() => setManualOpen(false)}
+        okText="添加"
+        width={500}
+      >
+        <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
+          <Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
+            <Input placeholder="+86xxxxxxxxx" />
+          </Form.Item>
+          <Form.Item name="session_file" label="Session文件路径" rules={[{ required: true }]}>
+            <Input placeholder="sessions/+86xxxxxxxxx.session" />
+          </Form.Item>
+          <Form.Item name="app_id" label="App ID" rules={[{ required: true }]}>
+            <InputNumber style={{ width: '100%' }} placeholder="从 my.telegram.org 获取" />
+          </Form.Item>
+          <Form.Item name="app_hash" label="App Hash" rules={[{ required: true }]}>
+            <Input placeholder="从 my.telegram.org 获取" />
+          </Form.Item>
+          <Form.Item name="remark" label="备注">
+            <Input placeholder="可选" />
+          </Form.Item>
+        </Form>
+      </Modal>
+    </div>
+  )
+}
+```
+
+- [ ] **Step 2: Build frontend**
+
+Run:
+```bash
+cd D:/spider/web && npm run build 2>&1 | tail -10
+```
+Expected: build succeeds (no TS errors).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web/src/pages/TgAccounts.tsx
+git commit -m "feat(web): TG protocol-number import UI with webkitdirectory upload"
+```
+
+---
+
+## Task 14: End-to-end smoke test
+
+**Files:** none (manual verification)
+
+- [ ] **Step 1: Rebuild and start the Docker stack**
+
+Run:
+```bash
+cd D:/spider/deploy && docker compose build api web && docker compose up -d
+```
+Expected: both containers start; `docker compose ps` shows `api` healthy after ~30s.
+
+- [ ] **Step 2: Ensure `TG_SECRET_KEY` is set in the API container**
+
+Edit `deploy/docker-compose.yml` → `api.environment` (if not already set), add:
+```yaml
+      - TG_SECRET_KEY=${TG_SECRET_KEY}
+```
+Create `deploy/.env` (NOT committed) with a real key:
+```bash
+cd D:/spider/deploy && echo "TG_SECRET_KEY=$(openssl rand -base64 32)" > .env
+docker compose up -d --force-recreate api
+```
+Expected: api healthy; no `TG_SECRET_KEY invalid` log.
+
+- [ ] **Step 3: Import a real protocol number via UI**
+
+1. Open `http://localhost:8300` in a browser, log in as admin.
+2. Navigate to TG 账号 page.
+3. Click "导入协议号" → in the file picker, select the folder `D:\spider\tgs\13252753163\`.
+4. Click 导入.
+
+Expected: toast "导入成功并测试通过" (or "冷却中" if the account is flood-waited — still counts as import success). New row appears with `来源=协议号`, `设备=Xiaomi Mix 4`, `状态=idle`|`online`, `导入状态=ok`.
+
+- [ ] **Step 4: Verify filesystem artifacts**
+
+Run on host:
+```bash
+ls D:/spider/sessions/
+```
+Expected: `13252753163.json` + `13252753163.origin/` (containing copies of the uploaded files).
+
+- [ ] **Step 5: Verify the account is usable**
+
+In the UI, go to Groups page → pick an existing group → click "克隆成员". The clone should succeed (or return a meaningful TG error like `USERNAME_NOT_OCCUPIED` — anything *other than* `no TG accounts configured` proves the account was acquired).
+
+- [ ] **Step 6: Verify duplicate protection**
+
+Re-import the same folder. Expected: toast `该手机号已存在` (HTTP 409).
+
+- [ ] **Step 7: Verify 2FA reveal**
+
+In the TG 账号 row, click `2FA`. Modal opens showing `xing` (or whatever the folder's `2fa_password.txt` contained).
+
+- [ ] **Step 8: Commit verification notes**
+
+If any small follow-up fixes were needed in prior tasks, commit them here. Otherwise this task has no commit.
+
+---
+
+## Self-Review Notes
+
+**Spec coverage check:**
+- §1 data model → Task 7
+- §2 file layout + docker + config → Tasks 6 (config), 12 (main.go mkdir); docker-compose already mounts sessions/ per existing config
+- §3 Pyrogram conversion → Tasks 1 (dep), 2 (dc addr), 3 (conversion), 4 (tdata fallback), 9 (orchestration)
+- §4 2FA crypto → Task 5; reveal endpoint Task 11
+- §5 device fingerprint → Task 8; plumbing in reloadAccounts Task 10; import insert Task 10
+- §6 frontend + API → Tasks 10 (import), 11 (reveal-2fa), 12 (routes), 13 (UI)
+- §7 error handling → built into Tasks 9–10 (pyrogram err, tdata err, FloodWait, duplicate, tmp dir retention via `tmpCleanupLoop`)
+- Non-goals → respected (no auto-scan, no re-login, no Pyrogram writeback, no tdata export, no batch)
+
+**Type consistency:**
+- `TgAccount.SDK` (DB field) → `Account.SystemVersion` (runtime) — mapping performed identically in Task 10's `reloadAccounts` and Task 10's `runTest`
+- `session.Data` fields used in Task 3 match what Task 9 writes via `session.Loader{}.Save`
+- HTTP status codes match spec: 400 (form error), 409 (dup), 422 (conversion fail), 500 (io/server)