package store import ( "context" "database/sql" "fmt" "time" "onesvm.com/onesvm/browser-server/internal/config" "onesvm.com/onesvm/browser-server/internal/contract" "onesvm.com/onesvm/browser-server/internal/fingerprint" ) // schemaV2 每节点模版 + Cookie 罐(P0/P1)。幂等 CREATE。 var schemaV2 = []string{ `CREATE TABLE IF NOT EXISTS fp_templates ( id TEXT PRIMARY KEY, node_id TEXT NOT NULL, impersonate TEXT NOT NULL, user_agent TEXT NOT NULL, sec_ch_ua TEXT NOT NULL, sec_ch_ua_platform TEXT NOT NULL, accept_language TEXT NOT NULL, os TEXT NOT NULL, chrome_major INTEGER NOT NULL, created_at TEXT NOT NULL )`, `CREATE INDEX IF NOT EXISTS idx_fp_templates_node ON fp_templates(node_id)`, `CREATE TABLE IF NOT EXISTS fp_state ( node_id TEXT PRIMARY KEY, active_id TEXT NOT NULL, updated_at TEXT NOT NULL )`, `CREATE TABLE IF NOT EXISTS session_cookies ( id INTEGER PRIMARY KEY AUTOINCREMENT, template_id TEXT NOT NULL, adapter_class TEXT NOT NULL, egress TEXT NOT NULL, etld TEXT NOT NULL, name TEXT NOT NULL, value TEXT NOT NULL, domain TEXT NOT NULL, path TEXT NOT NULL DEFAULT '/', expires_at TEXT NOT NULL, last_used_at TEXT NOT NULL, bytes INTEGER NOT NULL, UNIQUE(template_id, adapter_class, egress, etld, name, domain, path) )`, `CREATE INDEX IF NOT EXISTS idx_session_cookies_lookup ON session_cookies(template_id, adapter_class, egress, etld)`, `CREATE INDEX IF NOT EXISTS idx_session_cookies_expiry ON session_cookies(expires_at)`, `CREATE INDEX IF NOT EXISTS idx_session_cookies_lru ON session_cookies(last_used_at)`, } func migrateV2(d *DB) error { for _, stmt := range schemaV2 { if _, err := d.raw.Exec(stmt); err != nil { return fmt.Errorf("store: migration v2: %w (stmt=%s)", err, firstLine(stmt)) } } return nil } // EnsureNodeTemplates 首次为节点写入 TemplateCount 套模版并选定 active。 func (d *DB) EnsureNodeTemplates(ctx context.Context, nodeID string) (fingerprint.Profile, error) { if err := ctx.Err(); err != nil { return fingerprint.Profile{}, err } var n int if err := d.raw.QueryRowContext(ctx, `SELECT COUNT(*) FROM fp_templates WHERE node_id=?`, nodeID).Scan(&n); err != nil { return fingerprint.Profile{}, fmt.Errorf("store: 数模版: %w", err) } now := config.FormatTime(config.Now()) if n == 0 { set := fingerprint.AllocateSet(nodeID, fingerprint.TemplateCount) for _, p := range set { if _, err := d.raw.ExecContext(ctx, `INSERT INTO fp_templates (id, node_id, impersonate, user_agent, sec_ch_ua, sec_ch_ua_platform, accept_language, os, chrome_major, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)`, p.ID, nodeID, p.Impersonate, p.UserAgent, p.SecCHUA, p.SecCHUAPlatform, p.AcceptLanguage, p.OS, p.ChromeMajor, now); err != nil { return fingerprint.Profile{}, fmt.Errorf("store: 写模版: %w", err) } } active := fingerprint.PickActive(nodeID, set) if _, err := d.raw.ExecContext(ctx, `INSERT INTO fp_state (node_id, active_id, updated_at) VALUES (?,?,?)`, nodeID, active.ID, now); err != nil { return fingerprint.Profile{}, fmt.Errorf("store: 写 active: %w", err) } return active, nil } var activeID string err := d.raw.QueryRowContext(ctx, `SELECT active_id FROM fp_state WHERE node_id=?`, nodeID).Scan(&activeID) if err == sql.ErrNoRows || activeID == "" { set, err := d.ListTemplates(ctx, nodeID) if err != nil { return fingerprint.Profile{}, err } active := fingerprint.PickActive(nodeID, set) if _, err := d.raw.ExecContext(ctx, `INSERT INTO fp_state (node_id, active_id, updated_at) VALUES (?,?,?) ON CONFLICT(node_id) DO UPDATE SET active_id=excluded.active_id, updated_at=excluded.updated_at`, nodeID, active.ID, now); err != nil { return fingerprint.Profile{}, fmt.Errorf("store: 补 active: %w", err) } return active, nil } if err != nil { return fingerprint.Profile{}, fmt.Errorf("store: 读 active: %w", err) } return d.GetTemplate(ctx, activeID) } // ListTemplates 节点全部模版。 func (d *DB) ListTemplates(ctx context.Context, nodeID string) ([]fingerprint.Profile, error) { rows, err := d.raw.QueryContext(ctx, `SELECT id, impersonate, user_agent, sec_ch_ua, sec_ch_ua_platform, accept_language, os, chrome_major FROM fp_templates WHERE node_id=? ORDER BY id`, nodeID) if err != nil { return nil, fmt.Errorf("store: 列模版: %w", err) } defer rows.Close() var out []fingerprint.Profile for rows.Next() { var p fingerprint.Profile if err := rows.Scan(&p.ID, &p.Impersonate, &p.UserAgent, &p.SecCHUA, &p.SecCHUAPlatform, &p.AcceptLanguage, &p.OS, &p.ChromeMajor); err != nil { return nil, fmt.Errorf("store: 扫模版: %w", err) } p.SecCHUAMobile = "?0" out = append(out, p) } return out, rows.Err() } // GetTemplate 按 id 取模版。 func (d *DB) GetTemplate(ctx context.Context, id string) (fingerprint.Profile, error) { var p fingerprint.Profile err := d.raw.QueryRowContext(ctx, `SELECT id, impersonate, user_agent, sec_ch_ua, sec_ch_ua_platform, accept_language, os, chrome_major FROM fp_templates WHERE id=?`, id).Scan( &p.ID, &p.Impersonate, &p.UserAgent, &p.SecCHUA, &p.SecCHUAPlatform, &p.AcceptLanguage, &p.OS, &p.ChromeMajor) if err != nil { return p, fmt.Errorf("store: 读模版 %s: %w", id, err) } p.SecCHUAMobile = "?0" return p, nil } // SwitchActive 快速切到已落库的另一套模版。 func (d *DB) SwitchActive(ctx context.Context, nodeID, templateID string) error { now := config.FormatTime(config.Now()) _, err := d.raw.ExecContext(ctx, `UPDATE fp_state SET active_id=?, updated_at=? WHERE node_id=?`, templateID, now, nodeID) if err != nil { return fmt.Errorf("store: 切模版: %w", err) } return nil } // CookieKey 罐主键(模版 × 适配器档 × 出口 × 域)。 type CookieKey struct { TemplateID string AdapterClass string Egress string ETLD string } // LoadCookies 读一罐(未过期)。 func (d *DB) LoadCookies(ctx context.Context, k CookieKey) ([]contract.Cookie, error) { now := config.FormatTime(config.Now()) rows, err := d.raw.QueryContext(ctx, `SELECT name, value, domain, path, expires_at FROM session_cookies WHERE template_id=? AND adapter_class=? AND egress=? AND etld=? AND expires_at>?`, k.TemplateID, k.AdapterClass, k.Egress, k.ETLD, now) if err != nil { return nil, fmt.Errorf("store: 读罐: %w", err) } defer rows.Close() var out []contract.Cookie for rows.Next() { var c contract.Cookie var exp string if err := rows.Scan(&c.Name, &c.Value, &c.Domain, &c.Path, &exp); err != nil { return nil, fmt.Errorf("store: 扫罐: %w", err) } if t, err := config.ParseRFC3339(exp); err == nil { c.Expires = t.Unix() } out = append(out, c) } if err := rows.Err(); err != nil { return nil, err } if len(out) > 0 { _, _ = d.raw.ExecContext(ctx, `UPDATE session_cookies SET last_used_at=? WHERE template_id=? AND adapter_class=? AND egress=? AND etld=?`, now, k.TemplateID, k.AdapterClass, k.Egress, k.ETLD) } return out, nil } // ReplaceCookies 覆盖一域的罐(先删后插)。调用方已 Sanitize。 func (d *DB) ReplaceCookies(ctx context.Context, k CookieKey, cookies []contract.Cookie) error { nowS := config.FormatTime(config.Now()) tx, err := d.raw.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("store: 开事务: %w", err) } defer tx.Rollback() // Commit 后幂等 if _, err := tx.ExecContext(ctx, `DELETE FROM session_cookies WHERE template_id=? AND adapter_class=? AND egress=? AND etld=?`, k.TemplateID, k.AdapterClass, k.Egress, k.ETLD); err != nil { return fmt.Errorf("store: 清罐: %w", err) } for _, c := range cookies { expS := nowS if c.Expires > 0 { expS = config.FormatTime(time.Unix(c.Expires, 0)) } bytes := len(c.Name) + len(c.Value) + len(c.Domain) + len(c.Path) if _, err := tx.ExecContext(ctx, `INSERT INTO session_cookies (template_id, adapter_class, egress, etld, name, value, domain, path, expires_at, last_used_at, bytes) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, k.TemplateID, k.AdapterClass, k.Egress, k.ETLD, c.Name, c.Value, c.Domain, c.Path, expS, nowS, bytes); err != nil { return fmt.Errorf("store: 插 Cookie: %w", err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("store: 提交罐: %w", err) } return nil } // DropCookies 整域丢罐(投毒 / 换出口)。 func (d *DB) DropCookies(ctx context.Context, k CookieKey) error { _, err := d.raw.ExecContext(ctx, `DELETE FROM session_cookies WHERE template_id=? AND adapter_class=? AND egress=? AND etld=?`, k.TemplateID, k.AdapterClass, k.Egress, k.ETLD) if err != nil { return fmt.Errorf("store: 丢罐: %w", err) } return nil } // ReapCookies 过期删除 + 总字节/域数硬顶(LRU)。 func (d *DB) ReapCookies(ctx context.Context, maxETLD, maxBytes int) (int, error) { now := config.FormatTime(config.Now()) res, err := d.raw.ExecContext(ctx, `DELETE FROM session_cookies WHERE expires_at<=?`, now) if err != nil { return 0, fmt.Errorf("store: 收过期罐: %w", err) } n, _ := res.RowsAffected() cut, err := d.enforceJarCaps(ctx, maxETLD, maxBytes) return int(n) + cut, err } func (d *DB) enforceJarCaps(ctx context.Context, maxETLD, maxBytes int) (int, error) { cut := 0 // 按 (template,adapter,egress) 组限制域数。 rows, err := d.raw.QueryContext(ctx, `SELECT template_id, adapter_class, egress, etld, MAX(last_used_at) AS lu FROM session_cookies GROUP BY template_id, adapter_class, egress, etld ORDER BY template_id, adapter_class, egress, lu ASC`) if err != nil { return 0, fmt.Errorf("store: 域统计: %w", err) } type grp struct{ tpl, cls, eg, etld string } counts := map[string][]grp{} for rows.Next() { var g grp var lu string if err := rows.Scan(&g.tpl, &g.cls, &g.eg, &g.etld, &lu); err != nil { rows.Close() return cut, err } key := g.tpl + "\x00" + g.cls + "\x00" + g.eg counts[key] = append(counts[key], g) } if err := rows.Err(); err != nil { rows.Close() return cut, err } rows.Close() for _, list := range counts { if len(list) <= maxETLD { continue } overflow := list[:len(list)-maxETLD] // 已按 lu ASC,最旧在前 for _, g := range overflow { r, err := d.raw.ExecContext(ctx, `DELETE FROM session_cookies WHERE template_id=? AND adapter_class=? AND egress=? AND etld=?`, g.tpl, g.cls, g.eg, g.etld) if err != nil { return cut, fmt.Errorf("store: LRU 域: %w", err) } c, _ := r.RowsAffected() cut += int(c) } } var total int if err := d.raw.QueryRowContext(ctx, `SELECT COALESCE(SUM(bytes),0) FROM session_cookies`).Scan(&total); err != nil { return cut, fmt.Errorf("store: 字节合计: %w", err) } for total > maxBytes { var tpl, cls, eg, etld string err := d.raw.QueryRowContext(ctx, `SELECT template_id, adapter_class, egress, etld FROM session_cookies ORDER BY last_used_at ASC LIMIT 1`).Scan(&tpl, &cls, &eg, &etld) if err == sql.ErrNoRows { break } if err != nil { return cut, fmt.Errorf("store: 找最旧域: %w", err) } r, err := d.raw.ExecContext(ctx, `DELETE FROM session_cookies WHERE template_id=? AND adapter_class=? AND egress=? AND etld=?`, tpl, cls, eg, etld) if err != nil { return cut, err } c, _ := r.RowsAffected() cut += int(c) if err := d.raw.QueryRowContext(ctx, `SELECT COALESCE(SUM(bytes),0) FROM session_cookies`).Scan(&total); err != nil { return cut, err } } return cut, nil }