GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-05-03-geo-s1-infrastructure-plan.md
2026-05-20 21:39:12 +08:00

92 KiB
Raw Blame History

Geo S1 基础设施层 实施计划

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.

上位文档../specs/2026-05-03-geo-master-roadmap.md · ../specs/2026-05-03-geo-s1-infrastructure-design.md

Goal: 在 Neta 中新建 geo 模块,提供账号矩阵 / IP 池 / 指纹浏览器三位一体基础设施,支持账号-IP-Profile 严格 1:1:1 强绑定,启动 Chromium 让用户登录后自动抱回 cookie。

Architecture: 后端 Midway.js + Cool Admin 自动 CRUD浏览器层"两层正交"——IBrowserProvider 仅管进程生命周期S1 主实现 PlainChromiumProvider 用 playwright-cli + ChromiumBitBrowser/AntBrowser/AdsPower 全 stubBrowserAutomationService 统一用 playwright-cli -s={session} 做自动化cookie/state与底层浏览器解耦TypeORM synchronize:true 自动建表;菜单走 base_sys_menu 表;定时任务通过 task_info 表注册。

Tech Stack: Midway.js 3.20 / TypeScript 5.9 / TypeORM / MySQL 8 / Cool Admin 8 / Vue 3.5 / Element Plus 2.9 / playwright-cliNeta 已有 skill全局 CLI/ jest。


文件结构

后端文件(新建 17 个)

路径 职责
packages/backend/src/modules/geo/config.ts 模块配置
packages/backend/src/modules/geo/entity/account.ts geo_account Entity
packages/backend/src/modules/geo/entity/proxy_ip.ts geo_proxy_ip Entity
packages/backend/src/modules/geo/entity/browser_profile.ts geo_browser_profile Entity
packages/backend/src/modules/geo/service/encrypt.ts GeoEncryptService AES-256-GCM
packages/backend/src/modules/geo/service/browser_automation.ts 统一自动化层playwright-cli 包装)
packages/backend/src/modules/geo/service/proxy_ip.ts GeoProxyIpService
packages/backend/src/modules/geo/service/browser_profile.ts GeoBrowserProfileService
packages/backend/src/modules/geo/service/account.ts GeoAccountService 编排核心
packages/backend/src/modules/geo/controller/admin/proxy_ip.ts IP 池 Controller
packages/backend/src/modules/geo/controller/admin/browser_profile.ts 指纹浏览器 Controller
packages/backend/src/modules/geo/controller/admin/account.ts 账号 Controller
packages/backend/src/modules/geo/provider/proxy/interface.ts IProxyProvider
packages/backend/src/modules/geo/provider/proxy/local.ts LocalProxyProvider
packages/backend/src/modules/geo/provider/proxy/tianqi.ts 天启占位
packages/backend/src/modules/geo/provider/browser/interface.ts IBrowserProvider
packages/backend/src/modules/geo/provider/browser/plain_chromium.ts ★ S1 主实现playwright-cli + Chromium
packages/backend/src/modules/geo/provider/browser/bitbrowser.stub.ts BitBrowser 占位
packages/backend/src/modules/geo/provider/browser/ant_browser.stub.ts ant-browser 占位
packages/backend/src/modules/geo/provider/browser/adspower.stub.ts AdsPower 占位

测试文件(新建 8 个)

路径 测试对象
packages/backend/test/modules/geo/encrypt.test.ts GeoEncryptService
packages/backend/test/modules/geo/browser_automation.test.ts BrowserAutomationServicemock execSync
packages/backend/test/modules/geo/proxy_provider_local.test.ts LocalProxyProvider
packages/backend/test/modules/geo/proxy_provider_tianqi.test.ts TianqiProxyProvider 占位
packages/backend/test/modules/geo/browser_provider_plain_chromium.test.ts PlainChromiumProvidermock execSync
packages/backend/test/modules/geo/browser_provider_stub.test.ts BitBrowser/AntBrowser/AdsPower 占位
packages/backend/test/modules/geo/service_proxy_ip.test.ts GeoProxyIpService
packages/backend/test/modules/geo/service_browser_profile.test.ts GeoBrowserProfileService
packages/backend/test/modules/geo/service_account.test.ts GeoAccountService

前端文件(新建 5 个)

路径 职责
packages/frontend/src/modules/geo/config.ts 前端模块配置
packages/frontend/src/modules/geo/views/dashboard.vue 占位
packages/frontend/src/modules/geo/views/proxies.vue IP 池
packages/frontend/src/modules/geo/views/browser-profiles.vue 指纹浏览器
packages/frontend/src/modules/geo/views/accounts.vue 账号矩阵

数据库种子(通过 MCP mysql 工具执行 INSERT

  • base_sys_menu1 条目录 + 3 条菜单页面 + 14 条按钮权限
  • task_info1 条 cron 记录(geo.proxy_ip.healthCheckAll 每 6 小时)

依赖检查

实施前确认:

  • pnpm 已安装
  • 数据库 neta_test 可连通(packages/backend/src/config/config.local.ts
  • playwright-cli 全局可用(npx --no-install playwright-cli --versionnpm i -g @playwright/cli@latest
  • Neta 已有 packages/backend/skills/playwright-cli/SKILL.md

Task 0geo 模块脚手架

Files:

  • Create: packages/backend/src/modules/geo/config.ts

  • Step 1创建目录骨架

mkdir -p packages/backend/src/modules/geo/{entity,service,controller/admin,provider/proxy,provider/browser}
mkdir -p packages/backend/test/modules/geo
  • Step 2创建模块 config.ts
// packages/backend/src/modules/geo/config.ts
import { ModuleConfig } from '@cool-midway/core';

export default () => {
  return {
    name: 'GEO 生成式引擎优化',
    description: 'GEO 内容矩阵执行平台:账号矩阵 / IP 池 / 指纹浏览器 / 内容生产 / AI 引用监测',
    order: 60,
  } as ModuleConfig;
};
  • Step 3启动后端验证模块加载
cd packages/backend && pnpm dev

预期:日志中 GEO 生成式引擎优化 模块被加载无报错。Ctrl+C 停止。

  • Step 4commit
git add packages/backend/src/modules/geo/config.ts
git commit -m "feat(geo): 新建 geo 模块脚手架S1"

Task 1GeoEncryptService

Files:

  • Create: packages/backend/src/modules/geo/service/encrypt.ts

  • Test: packages/backend/test/modules/geo/encrypt.test.ts

  • Step 1写测试

// packages/backend/test/modules/geo/encrypt.test.ts
import { GeoEncryptService } from '../../../src/modules/geo/service/encrypt';

describe('GeoEncryptService', () => {
  const svc = new GeoEncryptService();

  beforeAll(() => {
    process.env.SKILL_SECRET_KEY = 'test-key-for-jest-only-do-not-use-in-prod';
  });

  it('加密后能解密回原文', () => {
    const plain = 'hello world 你好';
    const cipher = svc.encrypt(plain);
    expect(cipher).not.toBe(plain);
    expect(svc.decrypt(cipher)).toBe(plain);
  });

  it('每次加密同一字符串密文不同IV 随机)', () => {
    const plain = 'same input';
    const c1 = svc.encrypt(plain);
    const c2 = svc.encrypt(plain);
    expect(c1).not.toBe(c2);
  });

  it('密文被篡改时解密抛错', () => {
    const cipher = svc.encrypt('test');
    expect(() => svc.decrypt(cipher.slice(0, -4) + 'XXXX')).toThrow();
  });

  it('支持长 JSON 字符串cookies 场景)', () => {
    const cookies = JSON.stringify(Array(50).fill({ name: 'sess', value: 'abc'.repeat(20), domain: '.xiaohongshu.com' }));
    expect(svc.decrypt(svc.encrypt(cookies))).toBe(cookies);
  });
});
  • Step 2跑测试看失败
cd packages/backend && pnpm jest test/modules/geo/encrypt.test.ts

预期FAIL

  • Step 3实现
// packages/backend/src/modules/geo/service/encrypt.ts
import { Provide, Scope, ScopeEnum } from '@midwayjs/core';
import * as crypto from 'crypto';

@Provide()
@Scope(ScopeEnum.Singleton)
export class GeoEncryptService {
  private readonly algorithm = 'aes-256-gcm';

  private deriveKey(): Buffer {
    const raw = process.env.SKILL_SECRET_KEY || process.env.APP_SECRET;
    if (!raw) throw new Error('EncryptionKeyMissing: 必须配置 SKILL_SECRET_KEY 或 APP_SECRET');
    return crypto.createHash('sha256').update(raw).digest();
  }

  encrypt(plain: string): string {
    const key = this.deriveKey();
    const iv = crypto.randomBytes(12);
    const cipher = crypto.createCipheriv(this.algorithm, key, iv);
    const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
    const tag = cipher.getAuthTag();
    return Buffer.concat([iv, tag, ct]).toString('base64');
  }

  decrypt(cipherText: string): string {
    const key = this.deriveKey();
    const buf = Buffer.from(cipherText, 'base64');
    if (buf.length < 28) throw new Error('Invalid ciphertext: too short');
    const iv = buf.subarray(0, 12);
    const tag = buf.subarray(12, 28);
    const ct = buf.subarray(28);
    const decipher = crypto.createDecipheriv(this.algorithm, key, iv);
    decipher.setAuthTag(tag);
    return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
  }
}
  • Step 4跑测试看通过
cd packages/backend && pnpm jest test/modules/geo/encrypt.test.ts

预期PASS4 tests

  • Step 5commit
git add packages/backend/src/modules/geo/service/encrypt.ts packages/backend/test/modules/geo/encrypt.test.ts
git commit -m "feat(geo): 加密服务 GeoEncryptServiceAES-256-GCM"

Task 2BrowserAutomationService统一自动化层

关键设计:这是 S1 与未来 S3 共用的自动化基础。所有 cookie 抓取、状态保存都通过它,不再分散到各 Provider 里。

Files:

  • Create: packages/backend/src/modules/geo/service/browser_automation.ts

  • Test: packages/backend/test/modules/geo/browser_automation.test.ts

  • Step 1写测试

// packages/backend/test/modules/geo/browser_automation.test.ts
import { BrowserAutomationService } from '../../../src/modules/geo/service/browser_automation';

describe('BrowserAutomationService', () => {
  let svc: BrowserAutomationService;
  let execMock: jest.Mock;

  beforeEach(() => {
    execMock = jest.fn();
    svc = new BrowserAutomationService();
    (svc as any).exec = execMock;
  });

  it('getCookies 调 cookie-list --raw 并解析 JSON', async () => {
    execMock.mockReturnValue(JSON.stringify([{ name: 'sid', value: 'abc', domain: '.xhs.com' }]));
    const cookies = await svc.getCookies('geo-1', 'xhs.com');
    expect(execMock).toHaveBeenCalledWith(
      expect.stringContaining('playwright-cli -s=geo-1 --raw cookie-list --domain=xhs.com')
    );
    expect(cookies).toHaveLength(1);
    expect(cookies[0].name).toBe('sid');
  });

  it('getCookies 没传 domain 时不加 --domain', async () => {
    execMock.mockReturnValue('[]');
    await svc.getCookies('geo-1');
    expect(execMock).toHaveBeenCalledWith(expect.stringMatching(/cookie-list(?! --domain)/));
  });

  it('getCookies 输出非 JSON 时返回空数组', async () => {
    execMock.mockReturnValue('not-a-json');
    const cookies = await svc.getCookies('geo-1');
    expect(cookies).toEqual([]);
  });

  it('saveState 调 state-save', async () => {
    execMock.mockReturnValue('');
    await svc.saveState('geo-1', '/tmp/state.json');
    expect(execMock).toHaveBeenCalledWith(
      expect.stringContaining('playwright-cli -s=geo-1 state-save /tmp/state.json')
    );
  });

  it('loadState 调 state-load', async () => {
    execMock.mockReturnValue('');
    await svc.loadState('geo-1', '/tmp/state.json');
    expect(execMock).toHaveBeenCalledWith(
      expect.stringContaining('playwright-cli -s=geo-1 state-load /tmp/state.json')
    );
  });

  it('exec 失败时抛 BrowserAutomationFailed', async () => {
    execMock.mockImplementation(() => { throw new Error('cli not found'); });
    await expect(svc.getCookies('geo-1')).rejects.toThrow(/BrowserAutomationFailed/);
  });
});
  • Step 2跑测试看失败
cd packages/backend && pnpm jest test/modules/geo/browser_automation.test.ts

预期FAIL

  • Step 3实现
// packages/backend/src/modules/geo/service/browser_automation.ts
import { Provide, Scope, ScopeEnum, Logger, ILogger } from '@midwayjs/core';
import { execSync } from 'child_process';

export interface CookieItem {
  name: string;
  value: string;
  domain: string;
  path?: string;
  expires?: number;
  httpOnly?: boolean;
  secure?: boolean;
  sameSite?: string;
}

@Provide()
@Scope(ScopeEnum.Singleton)
export class BrowserAutomationService {
  @Logger()
  logger: ILogger;

  /** 单点 exec便于测试时 mock。子类/测试可重写。 */
  protected exec(cmd: string): string {
    return execSync(cmd, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
  }

  /** 列出 cookiedomain 可选 */
  async getCookies(sessionName: string, domain?: string): Promise<CookieItem[]> {
    const domainArg = domain ? ` --domain=${domain}` : '';
    const cmd = `playwright-cli -s=${sessionName} --raw cookie-list${domainArg}`;
    let out: string;
    try {
      out = this.exec(cmd);
    } catch (e: any) {
      throw new Error(`BrowserAutomationFailed: ${cmd}${e.message}`);
    }
    try {
      return JSON.parse(out.trim());
    } catch {
      this.logger.warn(`getCookies output not JSON: ${out.slice(0, 200)}`);
      return [];
    }
  }

  /** 保存完整登录态cookie + localStorage到文件 */
  async saveState(sessionName: string, filePath: string): Promise<void> {
    const cmd = `playwright-cli -s=${sessionName} state-save ${filePath}`;
    try {
      this.exec(cmd);
    } catch (e: any) {
      throw new Error(`BrowserAutomationFailed: ${cmd}${e.message}`);
    }
  }

  /** 从文件恢复登录态 */
  async loadState(sessionName: string, filePath: string): Promise<void> {
    const cmd = `playwright-cli -s=${sessionName} state-load ${filePath}`;
    try {
      this.exec(cmd);
    } catch (e: any) {
      throw new Error(`BrowserAutomationFailed: ${cmd}${e.message}`);
    }
  }

  /** S3 后续会增加 click / type / snapshot 等方法 */
}
  • Step 4跑测试看通过
cd packages/backend && pnpm jest test/modules/geo/browser_automation.test.ts

预期PASS6 tests

  • Step 5commit
git add packages/backend/src/modules/geo/service/browser_automation.ts packages/backend/test/modules/geo/browser_automation.test.ts
git commit -m "feat(geo): BrowserAutomationService 统一自动化层playwright-cli 包装)"

Task 3Provider 接口定义

Files:

  • Create: packages/backend/src/modules/geo/provider/proxy/interface.ts

  • Create: packages/backend/src/modules/geo/provider/browser/interface.ts

  • Step 1IProxyProvider

// packages/backend/src/modules/geo/provider/proxy/interface.ts
export interface AcquireOpts {
  region?: string;
  isp?: string;
  duration?: 'fixed' | 'rotating';
  extra?: Record<string, any>;
}

export interface ProxyInfo {
  externalId: string;
  mode: 'local' | 'third_party';
  protocol: 'http' | 'socks5';
  host?: string;
  port?: number;
  username?: string;
  password?: string;
  region?: string;
  isp?: string;
  expiresAt?: Date;
}

export interface HealthCheckResult {
  ok: boolean;
  latencyMs: number;
}

export interface IProxyProvider {
  readonly name: string;
  acquire(opts: AcquireOpts): Promise<ProxyInfo>;
  release(externalId: string): Promise<void>;
  healthCheck(p: ProxyInfo): Promise<HealthCheckResult>;
  list?(): Promise<ProxyInfo[]>;
}
  • Step 2IBrowserProvider仅进程生命周期不含自动化
// packages/backend/src/modules/geo/provider/browser/interface.ts
import type { ProxyInfo } from '../proxy/interface';

export interface CreateOpts {
  name: string;
  sessionName: string;
  userAgent?: string;
  osPlatform?: 'windows' | 'mac' | 'linux';
  timezone?: string;
  language?: string;
  screenW?: number;
  screenH?: number;
  fingerprint?: Record<string, any>;
}

export interface ProfileInfo {
  sessionName: string;
  profileDir?: string;
  configPath?: string;
  userAgent: string;
  osPlatform: string;
  timezone: string;
  language: string;
  screenW: number;
  screenH: number;
}

export interface IBrowserProvider {
  readonly name: string;
  /** 创建 profile 配置(不启动进程) */
  create(opts: CreateOpts): Promise<ProfileInfo>;
  /** 删除 profile 配置 */
  delete(profile: ProfileInfo): Promise<void>;
  /** 把代理写入 profile 配置 */
  attachProxy(profile: ProfileInfo, proxy: ProxyInfo): Promise<void>;
  /** 启动浏览器进程并打开 URL */
  open(profile: ProfileInfo, url?: string): Promise<void>;
  /** 关闭浏览器进程 */
  close(profile: ProfileInfo): Promise<void>;
}
  • Step 3tsc 类型检查
cd packages/backend && npx tsc --noEmit

预期:通过

  • Step 4commit
git add packages/backend/src/modules/geo/provider/
git commit -m "feat(geo): IProxyProvider 与 IBrowserProvider 接口(自动化已剥离到 BrowserAutomationService"

Task 4LocalProxyProvider

Files:

  • Create: packages/backend/src/modules/geo/provider/proxy/local.ts

  • Test: packages/backend/test/modules/geo/proxy_provider_local.test.ts

  • Step 1写测试

// packages/backend/test/modules/geo/proxy_provider_local.test.ts
import { LocalProxyProvider } from '../../../src/modules/geo/provider/proxy/local';

describe('LocalProxyProvider', () => {
  const p = new LocalProxyProvider();

  it('name 为 local', () => {
    expect(p.name).toBe('local');
  });

  it('acquire 返回 mode=local', async () => {
    const info = await p.acquire({ region: 'shanghai' });
    expect(info.mode).toBe('local');
    expect(info.externalId).toMatch(/^local-/);
    expect(info.host).toBeUndefined();
  });

  it('release 不抛错', async () => {
    await expect(p.release('local-xxx')).resolves.toBeUndefined();
  });

  it('healthCheck 返回 ok 与延迟', async () => {
    (global as any).fetch = jest.fn().mockResolvedValue({ ok: true });
    const info = await p.acquire({});
    const result = await p.healthCheck(info);
    expect(result.ok).toBe(true);
    expect(result.latencyMs).toBeGreaterThanOrEqual(0);
  });
});
  • Step 2跑测试看失败
cd packages/backend && pnpm jest test/modules/geo/proxy_provider_local.test.ts

预期FAIL

  • Step 3实现
// packages/backend/src/modules/geo/provider/proxy/local.ts
import { IProxyProvider, AcquireOpts, ProxyInfo, HealthCheckResult } from './interface';

const HEALTH_URL = 'https://www.baidu.com';
const HEALTH_TIMEOUT = 5000;

export class LocalProxyProvider implements IProxyProvider {
  readonly name = 'local';

  async acquire(opts: AcquireOpts): Promise<ProxyInfo> {
    return {
      externalId: `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
      mode: 'local',
      protocol: 'http',
      region: opts.region,
      isp: opts.isp,
    };
  }

  async release(_externalId: string): Promise<void> {}

  async healthCheck(_p: ProxyInfo): Promise<HealthCheckResult> {
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), HEALTH_TIMEOUT);
    const start = Date.now();
    try {
      const res = await fetch(HEALTH_URL, { signal: ctrl.signal, method: 'HEAD' });
      return { ok: res.ok, latencyMs: Date.now() - start };
    } catch {
      return { ok: false, latencyMs: Date.now() - start };
    } finally {
      clearTimeout(timer);
    }
  }
}
  • Step 4跑测试看通过
cd packages/backend && pnpm jest test/modules/geo/proxy_provider_local.test.ts

预期PASS4 tests

  • Step 5commit
git add packages/backend/src/modules/geo/provider/proxy/local.ts packages/backend/test/modules/geo/proxy_provider_local.test.ts
git commit -m "feat(geo): LocalProxyProvider"

Task 5占位 ProviderTianqi + 浏览器三个 stub

Files:

  • Create: packages/backend/src/modules/geo/provider/proxy/tianqi.ts

  • Create: packages/backend/src/modules/geo/provider/browser/bitbrowser.stub.ts

  • Create: packages/backend/src/modules/geo/provider/browser/ant_browser.stub.ts

  • Create: packages/backend/src/modules/geo/provider/browser/adspower.stub.ts

  • Test: packages/backend/test/modules/geo/proxy_provider_tianqi.test.ts

  • Test: packages/backend/test/modules/geo/browser_provider_stub.test.ts

  • Step 1写测试

// packages/backend/test/modules/geo/proxy_provider_tianqi.test.ts
import { TianqiProxyProvider } from '../../../src/modules/geo/provider/proxy/tianqi';

describe('TianqiProxyProvider占位', () => {
  const p = new TianqiProxyProvider();

  it('name 为 tianqi', () => {
    expect(p.name).toBe('tianqi');
  });

  it('acquire 抛 NotImplemented', async () => {
    await expect(p.acquire({})).rejects.toThrow(/NotImplemented/);
  });

  it('healthCheck 抛 NotImplemented', async () => {
    await expect(p.healthCheck({} as any)).rejects.toThrow(/NotImplemented/);
  });
});
// packages/backend/test/modules/geo/browser_provider_stub.test.ts
import { BitBrowserStubProvider } from '../../../src/modules/geo/provider/browser/bitbrowser.stub';
import { AntBrowserStubProvider } from '../../../src/modules/geo/provider/browser/ant_browser.stub';
import { AdsPowerStubProvider } from '../../../src/modules/geo/provider/browser/adspower.stub';

describe('Browser 占位 Provider', () => {
  it('BitBrowser stub 抛 NotImplemented', async () => {
    const p = new BitBrowserStubProvider();
    expect(p.name).toBe('bitbrowser');
    await expect(p.create({ name: 't', sessionName: 's' })).rejects.toThrow(/NotImplemented/);
  });

  it('AntBrowser stub 抛 NotImplemented', async () => {
    const p = new AntBrowserStubProvider();
    expect(p.name).toBe('ant_browser');
    await expect(p.create({ name: 't', sessionName: 's' })).rejects.toThrow(/NotImplemented/);
  });

  it('AdsPower stub 抛 NotImplemented', async () => {
    const p = new AdsPowerStubProvider();
    expect(p.name).toBe('adspower');
    await expect(p.create({ name: 't', sessionName: 's' })).rejects.toThrow(/NotImplemented/);
  });
});
  • Step 2跑测试看失败
cd packages/backend && pnpm jest test/modules/geo/proxy_provider_tianqi.test.ts test/modules/geo/browser_provider_stub.test.ts

预期FAIL

  • Step 3实现 4 个占位
// packages/backend/src/modules/geo/provider/proxy/tianqi.ts
// 天启 HTTP 代理 Provider占位
// TODO: 等用户提供天启 HTTP 接入文档后实现
import { IProxyProvider, AcquireOpts, ProxyInfo, HealthCheckResult } from './interface';

export class TianqiProxyProvider implements IProxyProvider {
  readonly name = 'tianqi';
  async acquire(_opts: AcquireOpts): Promise<ProxyInfo> {
    throw new Error('NotImplemented: TianqiProxyProvider 等待对接文档');
  }
  async release(_externalId: string): Promise<void> {
    throw new Error('NotImplemented: TianqiProxyProvider 等待对接文档');
  }
  async healthCheck(_p: ProxyInfo): Promise<HealthCheckResult> {
    throw new Error('NotImplemented: TianqiProxyProvider 等待对接文档');
  }
}
// packages/backend/src/modules/geo/provider/browser/bitbrowser.stub.ts
// BitBrowser 占位。未来实现:调 BitBrowser Local API 启动 profileplaywright-cli attach 进去
import { IBrowserProvider, CreateOpts, ProfileInfo } from './interface';
import type { ProxyInfo } from '../proxy/interface';

export class BitBrowserStubProvider implements IBrowserProvider {
  readonly name = 'bitbrowser';
  async create(_o: CreateOpts): Promise<ProfileInfo> { throw new Error('NotImplemented: BitBrowser 占位'); }
  async delete(_p: ProfileInfo): Promise<void> { throw new Error('NotImplemented: BitBrowser 占位'); }
  async attachProxy(_p: ProfileInfo, _x: ProxyInfo): Promise<void> { throw new Error('NotImplemented: BitBrowser 占位'); }
  async open(_p: ProfileInfo, _url?: string): Promise<void> { throw new Error('NotImplemented: BitBrowser 占位'); }
  async close(_p: ProfileInfo): Promise<void> { throw new Error('NotImplemented: BitBrowser 占位'); }
}
// packages/backend/src/modules/geo/provider/browser/ant_browser.stub.ts
// ant-browser 占位。等用户提供源码后实现
import { IBrowserProvider, CreateOpts, ProfileInfo } from './interface';
import type { ProxyInfo } from '../proxy/interface';

export class AntBrowserStubProvider implements IBrowserProvider {
  readonly name = 'ant_browser';
  async create(_o: CreateOpts): Promise<ProfileInfo> { throw new Error('NotImplemented: ant-browser 占位'); }
  async delete(_p: ProfileInfo): Promise<void> { throw new Error('NotImplemented: ant-browser 占位'); }
  async attachProxy(_p: ProfileInfo, _x: ProxyInfo): Promise<void> { throw new Error('NotImplemented: ant-browser 占位'); }
  async open(_p: ProfileInfo, _url?: string): Promise<void> { throw new Error('NotImplemented: ant-browser 占位'); }
  async close(_p: ProfileInfo): Promise<void> { throw new Error('NotImplemented: ant-browser 占位'); }
}
// packages/backend/src/modules/geo/provider/browser/adspower.stub.ts
import { IBrowserProvider, CreateOpts, ProfileInfo } from './interface';
import type { ProxyInfo } from '../proxy/interface';

export class AdsPowerStubProvider implements IBrowserProvider {
  readonly name = 'adspower';
  async create(_o: CreateOpts): Promise<ProfileInfo> { throw new Error('NotImplemented: AdsPower 占位'); }
  async delete(_p: ProfileInfo): Promise<void> { throw new Error('NotImplemented: AdsPower 占位'); }
  async attachProxy(_p: ProfileInfo, _x: ProxyInfo): Promise<void> { throw new Error('NotImplemented: AdsPower 占位'); }
  async open(_p: ProfileInfo, _url?: string): Promise<void> { throw new Error('NotImplemented: AdsPower 占位'); }
  async close(_p: ProfileInfo): Promise<void> { throw new Error('NotImplemented: AdsPower 占位'); }
}
  • Step 4跑测试看通过
cd packages/backend && pnpm jest test/modules/geo/proxy_provider_tianqi.test.ts test/modules/geo/browser_provider_stub.test.ts

预期PASS6 tests

  • Step 5commit
git add packages/backend/src/modules/geo/provider/proxy/tianqi.ts packages/backend/src/modules/geo/provider/browser/*.stub.ts packages/backend/test/modules/geo/proxy_provider_tianqi.test.ts packages/backend/test/modules/geo/browser_provider_stub.test.ts
git commit -m "feat(geo): Tianqi + 三个浏览器 Provider 占位实现"

Task 6PlainChromiumProviderS1 主实现)

Files:

  • Create: packages/backend/src/modules/geo/provider/browser/plain_chromium.ts

  • Test: packages/backend/test/modules/geo/browser_provider_plain_chromium.test.ts

  • Step 1写测试mock execSync + fs

// packages/backend/test/modules/geo/browser_provider_plain_chromium.test.ts
import { PlainChromiumProvider } from '../../../src/modules/geo/provider/browser/plain_chromium';

describe('PlainChromiumProvider', () => {
  let p: PlainChromiumProvider;
  let execMock: jest.Mock;
  let writeMock: jest.Mock;
  let unlinkMock: jest.Mock;

  beforeEach(() => {
    execMock = jest.fn();
    writeMock = jest.fn();
    unlinkMock = jest.fn();
    p = new PlainChromiumProvider({ baseDir: '/tmp/geo-test' });
    (p as any).exec = execMock;
    (p as any).writeFile = writeMock;
    (p as any).rm = unlinkMock;
  });

  it('name 为 plain_chromium', () => {
    expect(p.name).toBe('plain_chromium');
  });

  it('create 写 profile 目录与代理 config返回 ProfileInfo', async () => {
    const info = await p.create({
      name: 'acc1', sessionName: 'geo-1',
      userAgent: 'Mozilla/5.0', osPlatform: 'windows',
      timezone: 'Asia/Shanghai', language: 'zh-CN', screenW: 1920, screenH: 1080,
    });
    expect(info.sessionName).toBe('geo-1');
    expect(info.profileDir).toMatch(/geo-1/);
    expect(info.configPath).toMatch(/geo-1.*\.json$/);
  });

  it('attachProxy 把代理配置写入 config 文件', async () => {
    const profile = { sessionName: 'geo-1', configPath: '/tmp/geo-test/configs/geo-1.json' } as any;
    await p.attachProxy(profile, {
      externalId: 'p', mode: 'third_party', protocol: 'http',
      host: '1.2.3.4', port: 8080, username: 'u', password: 'pp',
    });
    expect(writeMock).toHaveBeenCalled();
    const written = writeMock.mock.calls[0][1];
    expect(written).toContain('"server": "http://1.2.3.4:8080"');
    expect(written).toContain('"username": "u"');
  });

  it('open 调 playwright-cli open --persistent --headed', async () => {
    await p.open(
      { sessionName: 'geo-1', profileDir: '/tmp/p', configPath: '/tmp/c.json' } as any,
      'https://example.com'
    );
    expect(execMock).toHaveBeenCalledWith(
      expect.stringContaining('playwright-cli -s=geo-1 open https://example.com --persistent --profile=/tmp/p --headed --config=/tmp/c.json')
    );
  });

  it('close 调 playwright-cli close', async () => {
    await p.close({ sessionName: 'geo-1' } as any);
    expect(execMock).toHaveBeenCalledWith(expect.stringContaining('playwright-cli -s=geo-1 close'));
  });

  it('delete 调 delete-data 并清理 config', async () => {
    await p.delete({ sessionName: 'geo-1', configPath: '/tmp/c.json', profileDir: '/tmp/p' } as any);
    expect(execMock).toHaveBeenCalledWith(expect.stringContaining('playwright-cli -s=geo-1 delete-data'));
    expect(unlinkMock).toHaveBeenCalled();
  });

  it('exec 失败抛 BrowserProviderUnavailable', async () => {
    execMock.mockImplementation(() => { throw new Error('cli not found'); });
    await expect(
      p.open({ sessionName: 'geo-1', profileDir: '/tmp/p', configPath: '/tmp/c.json' } as any)
    ).rejects.toThrow(/BrowserProviderUnavailable/);
  });
});
  • Step 2跑测试看失败
cd packages/backend && pnpm jest test/modules/geo/browser_provider_plain_chromium.test.ts

预期FAIL

  • Step 3实现
// packages/backend/src/modules/geo/provider/browser/plain_chromium.ts
// S1 主实现:通过 playwright-cli 启动 Chromium每个账号一个命名 session + 持久化 profile + 代理 config
import { execSync } from 'child_process';
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
import * as path from 'path';
import { IBrowserProvider, CreateOpts, ProfileInfo } from './interface';
import type { ProxyInfo } from '../proxy/interface';

export interface PlainChromiumOptions {
  baseDir?: string;
}

export class PlainChromiumProvider implements IBrowserProvider {
  readonly name = 'plain_chromium';
  private readonly baseDir: string;

  constructor(opts: PlainChromiumOptions = {}) {
    this.baseDir = opts.baseDir
      || process.env.GEO_BROWSER_DATA_DIR
      || path.join(process.cwd(), '.geo-browser');
  }

  // 单点封装,便于测试 mock
  protected exec(cmd: string): string {
    return execSync(cmd, { encoding: 'utf8' });
  }
  protected writeFile(p: string, content: string): void {
    mkdirSync(path.dirname(p), { recursive: true });
    writeFileSync(p, content, 'utf8');
  }
  protected rm(p: string): void {
    if (existsSync(p)) rmSync(p, { recursive: true, force: true });
  }

  async create(opts: CreateOpts): Promise<ProfileInfo> {
    const profileDir = path.join(this.baseDir, 'profiles', opts.sessionName);
    const configPath = path.join(this.baseDir, 'configs', `${opts.sessionName}.json`);
    mkdirSync(profileDir, { recursive: true });
    // 初始 config无代理
    this.writeFile(configPath, JSON.stringify({ launchOptions: {} }, null, 2));
    return {
      sessionName: opts.sessionName,
      profileDir,
      configPath,
      userAgent: opts.userAgent || '',
      osPlatform: opts.osPlatform || 'windows',
      timezone: opts.timezone || 'Asia/Shanghai',
      language: opts.language || 'zh-CN',
      screenW: opts.screenW || 1920,
      screenH: opts.screenH || 1080,
    };
  }

  async delete(profile: ProfileInfo): Promise<void> {
    try {
      this.exec(`playwright-cli -s=${profile.sessionName} delete-data`);
    } catch {/* 忽略:可能未启动 */}
    if (profile.configPath) this.rm(profile.configPath);
    if (profile.profileDir) this.rm(profile.profileDir);
  }

  async attachProxy(profile: ProfileInfo, proxy: ProxyInfo): Promise<void> {
    if (!profile.configPath) throw new Error('attachProxy: profile.configPath 缺失');
    const config: any = { launchOptions: {} };
    if (proxy.mode === 'third_party' && proxy.host && proxy.port) {
      config.launchOptions.proxy = {
        server: `${proxy.protocol}://${proxy.host}:${proxy.port}`,
      };
      if (proxy.username) config.launchOptions.proxy.username = proxy.username;
      if (proxy.password) config.launchOptions.proxy.password = proxy.password;
    }
    this.writeFile(profile.configPath, JSON.stringify(config, null, 2));
  }

  async open(profile: ProfileInfo, url?: string): Promise<void> {
    const parts = [`playwright-cli -s=${profile.sessionName} open`];
    if (url) parts.push(url);
    parts.push('--persistent');
    if (profile.profileDir) parts.push(`--profile=${profile.profileDir}`);
    parts.push('--headed');
    if (profile.configPath) parts.push(`--config=${profile.configPath}`);
    const cmd = parts.join(' ');
    try {
      this.exec(cmd);
    } catch (e: any) {
      throw new Error(`BrowserProviderUnavailable: ${cmd}${e.message}`);
    }
  }

  async close(profile: ProfileInfo): Promise<void> {
    try {
      this.exec(`playwright-cli -s=${profile.sessionName} close`);
    } catch {/* 容忍 */}
  }
}
  • Step 4跑测试看通过
cd packages/backend && pnpm jest test/modules/geo/browser_provider_plain_chromium.test.ts

预期PASS7 tests

  • Step 5commit
git add packages/backend/src/modules/geo/provider/browser/plain_chromium.ts packages/backend/test/modules/geo/browser_provider_plain_chromium.test.ts
git commit -m "feat(geo): PlainChromiumProviderS1 主实现playwright-cli + Chromium"

Task 7GeoProxyIp Entity

Files:

  • Create: packages/backend/src/modules/geo/entity/proxy_ip.ts

  • Step 1实现

// packages/backend/src/modules/geo/entity/proxy_ip.ts
import { BaseEntity } from '../../base/entity/base';
import { Column, Entity, Index } from 'typeorm';

@Entity('geo_proxy_ip')
export class GeoProxyIpEntity extends BaseEntity {
  @Column({ comment: '名称' })
  name: string;

  @Column({ comment: '所属 Providerlocal / tianqi', length: 32 })
  provider: string;

  @Column({ comment: '模式local / third_party', length: 16 })
  mode: string;

  @Column({ comment: '代理主机', nullable: true, length: 128 })
  host: string;

  @Column({ comment: '代理端口', nullable: true })
  port: number;

  @Column({ comment: '协议 http/socks5', length: 8, default: 'http' })
  protocol: string;

  @Column({ comment: '用户名(加密)', nullable: true, length: 256 })
  username: string;

  @Column({ comment: '密码(加密)', nullable: true, length: 512 })
  password: string;

  @Column({ comment: '区域', nullable: true, length: 64 })
  region: string;

  @Column({ comment: 'ISP', nullable: true, length: 32 })
  isp: string;

  @Column({ comment: 'Provider 侧外部 ID', nullable: true, length: 128 })
  externalId: string;

  @Index({ unique: true, where: 'bind_account_id IS NOT NULL' })
  @Column({ comment: '绑定账号 ID强 1:1', nullable: true })
  bindAccountId: number;

  @Column({ comment: '状态 active/expired/error/unbound', length: 16, default: 'unbound' })
  status: string;

  @Column({ comment: '健康检查延迟 ms', nullable: true })
  latencyMs: number;

  @Column({ comment: '上次检查时间', type: 'datetime', nullable: true })
  lastCheckAt: Date;

  @Column({ comment: '过期时间', type: 'datetime', nullable: true })
  expiresAt: Date;

  @Column({ comment: '扩展字段', type: 'json', nullable: true })
  extra: any;
}
  • Step 2启动后端验证表自动创建
cd packages/backend && pnpm dev

用 MCP 验证:mcp__mysql__list_tables 应包含 geo_proxy_ip。Ctrl+C 停止。

  • Step 3commit
git add packages/backend/src/modules/geo/entity/proxy_ip.ts
git commit -m "feat(geo): GeoProxyIp Entity"

Task 8GeoBrowserProfile Entity

Files:

  • Create: packages/backend/src/modules/geo/entity/browser_profile.ts

  • Step 1实现

// packages/backend/src/modules/geo/entity/browser_profile.ts
import { BaseEntity } from '../../base/entity/base';
import { Column, Entity, Index } from 'typeorm';

@Entity('geo_browser_profile')
export class GeoBrowserProfileEntity extends BaseEntity {
  @Column({ comment: '名称' })
  name: string;

  @Column({ comment: 'Providerplain_chromium / bitbrowser / ant_browser / adspower', length: 32 })
  provider: string;

  @Index()
  @Column({ comment: 'playwright-cli 命名 session唯一标识', length: 128 })
  sessionName: string;

  @Index({ unique: true, where: 'account_id IS NOT NULL' })
  @Column({ comment: '绑定账号 ID强 1:1', nullable: true })
  accountId: number;

  @Column({ comment: 'profile 持久化目录', nullable: true, length: 512 })
  profileDir: string;

  @Column({ comment: '代理配置文件路径', nullable: true, length: 512 })
  configPath: string;

  @Column({ comment: 'UserAgent', nullable: true, length: 512 })
  userAgent: string;

  @Column({ comment: 'OS 平台', nullable: true, length: 32 })
  osPlatform: string;

  @Column({ comment: '时区', nullable: true, length: 32 })
  timezone: string;

  @Column({ comment: '语言', nullable: true, length: 16 })
  language: string;

  @Column({ comment: '屏幕宽', nullable: true })
  screenW: number;

  @Column({ comment: '屏幕高', nullable: true })
  screenH: number;

  @Column({ comment: '深度指纹S1 不填,等 ant-browser', type: 'json', nullable: true })
  fingerprint: any;

  @Column({ comment: '上次打开时间', type: 'datetime', nullable: true })
  lastOpenAt: Date;

  @Column({ comment: '状态 created/running/closed/error/deleted', length: 16, default: 'created' })
  status: string;

  @Column({ comment: '扩展字段', type: 'json', nullable: true })
  extra: any;
}
  • Step 2启动后端验证
cd packages/backend && pnpm dev

MCP list_tables 验证 geo_browser_profile。Ctrl+C。

  • Step 3commit
git add packages/backend/src/modules/geo/entity/browser_profile.ts
git commit -m "feat(geo): GeoBrowserProfile EntitysessionName/profileDir/configPath"

Task 9GeoAccount Entity

Files:

  • Create: packages/backend/src/modules/geo/entity/account.ts

  • Step 1实现

// packages/backend/src/modules/geo/entity/account.ts
import { BaseEntity } from '../../base/entity/base';
import { Column, Entity, Index, Unique } from 'typeorm';

@Entity('geo_account')
@Unique('uk_geo_account_platform_login', ['platform', 'loginAccount'])
export class GeoAccountEntity extends BaseEntity {
  @Column({ comment: '账号备注名/昵称' })
  name: string;

  @Index()
  @Column({ comment: '平台 xiaohongshu/douyin/weibo/zhihu/wechat', length: 32 })
  platform: string;

  @Column({ comment: '登录账号/手机号', length: 128 })
  loginAccount: string;

  @Column({ comment: 'cookies加密 JSON', type: 'text', nullable: true })
  cookies: string;

  @Column({ comment: 'Cookie 抓取时间', type: 'datetime', nullable: true })
  cookieCapturedAt: Date;

  @Column({ comment: '登录状态 never/logged_in/expired', length: 16, default: 'never' })
  loginStatus: string;

  @Column({ comment: '账号状态 draft/active/risky/banned/deleted', length: 16, default: 'draft' })
  status: string;

  @Index({ unique: true, where: 'proxy_id IS NOT NULL' })
  @Column({ comment: '绑定 IP', nullable: true })
  proxyId: number;

  @Index({ unique: true, where: 'browser_profile_id IS NOT NULL' })
  @Column({ comment: '绑定指纹浏览器 profile', nullable: true })
  browserProfileId: number;

  @Column({ comment: '人设 IDS2 用)', nullable: true })
  personaId: number;

  @Column({ comment: 'Agent 配置 IDS3 用)', nullable: true })
  agentConfigId: number;

  @Column({ comment: '上次活跃时间', type: 'datetime', nullable: true })
  lastActiveAt: Date;

  @Column({ comment: '平台特定字段', type: 'json', nullable: true })
  extra: any;
}
  • Step 2启动验证三张表全部就位

mcp__mysql__list_tables → geo_account / geo_proxy_ip / geo_browser_profile 都在。

  • Step 3commit
git add packages/backend/src/modules/geo/entity/account.ts
git commit -m "feat(geo): GeoAccount Entity"

Task 10GeoProxyIpService

Files:

  • Create: packages/backend/src/modules/geo/service/proxy_ip.ts

  • Test: packages/backend/test/modules/geo/service_proxy_ip.test.ts

  • Step 1写测试

// packages/backend/test/modules/geo/service_proxy_ip.test.ts
import { GeoProxyIpService } from '../../../src/modules/geo/service/proxy_ip';
import { LocalProxyProvider } from '../../../src/modules/geo/provider/proxy/local';

describe('GeoProxyIpService', () => {
  let svc: GeoProxyIpService;
  let repo: any;

  beforeEach(() => {
    process.env.SKILL_SECRET_KEY = 'test-key';
    repo = {
      save: jest.fn(async (e) => ({ id: 1, ...e })),
      findOneBy: jest.fn(),
      find: jest.fn().mockResolvedValue([]),
      update: jest.fn(),
      create: (e: any) => e,
    };
    svc = new GeoProxyIpService();
    (svc as any).proxyIpEntity = repo;
    (svc as any).encryptService = {
      encrypt: (s: string) => `enc:${s}`,
      decrypt: (s: string) => s.replace(/^enc:/, ''),
    };
  });

  it('getProvider("local") 返回 LocalProxyProvider', () => {
    expect(svc.getProvider('local')).toBeInstanceOf(LocalProxyProvider);
  });

  it('getProvider 未知名称抛错', () => {
    expect(() => svc.getProvider('unknown')).toThrow();
  });

  it('healthCheckAll 遍历 active IP 并更新状态', async () => {
    repo.find.mockResolvedValue([
      { id: 1, provider: 'local', mode: 'local', protocol: 'http', status: 'active' },
    ]);
    (global as any).fetch = jest.fn().mockResolvedValue({ ok: true });
    await svc.healthCheckAll();
    expect(repo.update).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'active' }));
  });

  it('toProxyInfo 解密 username/password', () => {
    const info = svc.toProxyInfo({ id: 1, externalId: 'p', mode: 'third_party', protocol: 'http', host: 'h', port: 8080, username: 'enc:user', password: 'enc:pass' } as any);
    expect(info.username).toBe('user');
    expect(info.password).toBe('pass');
  });
});
  • Step 2跑测试看失败
cd packages/backend && pnpm jest test/modules/geo/service_proxy_ip.test.ts
  • Step 3实现
// packages/backend/src/modules/geo/service/proxy_ip.ts
import { Provide, Inject, ILogger, Logger } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { GeoProxyIpEntity } from '../entity/proxy_ip';
import { GeoEncryptService } from './encrypt';
import { IProxyProvider, AcquireOpts, ProxyInfo } from '../provider/proxy/interface';
import { LocalProxyProvider } from '../provider/proxy/local';
import { TianqiProxyProvider } from '../provider/proxy/tianqi';

@Provide()
export class GeoProxyIpService extends BaseService {
  @InjectEntityModel(GeoProxyIpEntity)
  proxyIpEntity: Repository<GeoProxyIpEntity>;

  @Inject()
  encryptService: GeoEncryptService;

  @Logger()
  logger: ILogger;

  private readonly providers = new Map<string, IProxyProvider>([
    ['local', new LocalProxyProvider()],
    ['tianqi', new TianqiProxyProvider()],
  ]);

  getProvider(name: string): IProxyProvider {
    const p = this.providers.get(name);
    if (!p) throw new Error(`Unknown proxy provider: ${name}`);
    return p;
  }

  async acquire(dto: { provider: string } & AcquireOpts): Promise<ProxyInfo> {
    return this.getProvider(dto.provider).acquire(dto);
  }

  async persist(info: ProxyInfo & { name?: string; provider?: string }, manager?: any): Promise<GeoProxyIpEntity> {
    const repo = manager ? manager.getRepository(GeoProxyIpEntity) : this.proxyIpEntity;
    const entity = repo.create({
      name: info.name || `${info.mode}-${info.region || 'unknown'}`,
      provider: info.provider || (info.mode === 'local' ? 'local' : 'tianqi'),
      mode: info.mode,
      host: info.host,
      port: info.port,
      protocol: info.protocol,
      username: info.username ? this.encryptService.encrypt(info.username) : null,
      password: info.password ? this.encryptService.encrypt(info.password) : null,
      region: info.region,
      isp: info.isp,
      externalId: info.externalId,
      status: 'active',
      expiresAt: info.expiresAt,
    });
    return repo.save(entity);
  }

  toProxyInfo(e: GeoProxyIpEntity): ProxyInfo {
    return {
      externalId: e.externalId,
      mode: e.mode as any,
      protocol: e.protocol as any,
      host: e.host,
      port: e.port,
      username: e.username ? this.encryptService.decrypt(e.username) : undefined,
      password: e.password ? this.encryptService.decrypt(e.password) : undefined,
      region: e.region,
      isp: e.isp,
      expiresAt: e.expiresAt,
    };
  }

  async healthCheckAll(): Promise<void> {
    const ips = await this.proxyIpEntity.find({ where: { status: 'active' } });
    for (const ip of ips) {
      try {
        const result = await this.getProvider(ip.provider).healthCheck(this.toProxyInfo(ip));
        await this.proxyIpEntity.update(ip.id, {
          latencyMs: result.latencyMs,
          lastCheckAt: new Date(),
          status: result.ok ? 'active' : 'error',
        });
        if (!result.ok) this.logger.warn(`[GEO] IP ${ip.id} health check failed`);
      } catch (e: any) {
        this.logger.error(`[GEO] IP ${ip.id} health check error: ${e.message}`);
      }
    }
  }
}
  • Step 4跑测试看通过
cd packages/backend && pnpm jest test/modules/geo/service_proxy_ip.test.ts
  • Step 5commit
git add packages/backend/src/modules/geo/service/proxy_ip.ts packages/backend/test/modules/geo/service_proxy_ip.test.ts
git commit -m "feat(geo): GeoProxyIpService"

Task 11GeoBrowserProfileService

Files:

  • Create: packages/backend/src/modules/geo/service/browser_profile.ts

  • Test: packages/backend/test/modules/geo/service_browser_profile.test.ts

  • Step 1写测试

// packages/backend/test/modules/geo/service_browser_profile.test.ts
import { GeoBrowserProfileService } from '../../../src/modules/geo/service/browser_profile';

describe('GeoBrowserProfileService', () => {
  let svc: GeoBrowserProfileService;
  let repo: any;
  let mockProvider: any;

  beforeEach(() => {
    repo = {
      save: jest.fn(async (e) => ({ id: 1, ...e })),
      findOneBy: jest.fn(),
      update: jest.fn(),
      create: (e: any) => e,
    };
    mockProvider = {
      name: 'plain_chromium',
      create: jest.fn().mockResolvedValue({
        sessionName: 'geo-1', profileDir: '/p', configPath: '/c.json',
        userAgent: 'UA', osPlatform: 'windows', timezone: 'Asia/Shanghai',
        language: 'zh-CN', screenW: 1920, screenH: 1080,
      }),
      delete: jest.fn(),
      attachProxy: jest.fn(),
      open: jest.fn(),
      close: jest.fn(),
    };
    svc = new GeoBrowserProfileService();
    (svc as any).profileEntity = repo;
    (svc as any).providers = new Map([['plain_chromium', mockProvider]]);
  });

  it('createAndPersist 调 provider.create 并写库', async () => {
    const e = await svc.createAndPersist('plain_chromium', { name: 't', sessionName: 'geo-1' });
    expect(mockProvider.create).toHaveBeenCalled();
    expect(repo.save).toHaveBeenCalled();
    expect(e.sessionName).toBe('geo-1');
  });

  it('open 调 provider.open 并更新 status', async () => {
    repo.findOneBy.mockResolvedValue({ id: 1, provider: 'plain_chromium', sessionName: 'geo-1', profileDir: '/p', configPath: '/c.json' });
    await svc.open(1, 'https://example.com');
    expect(mockProvider.open).toHaveBeenCalled();
    expect(repo.update).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'running' }));
  });

  it('attachProxy 调 provider.attachProxy', async () => {
    repo.findOneBy.mockResolvedValue({ id: 1, provider: 'plain_chromium', sessionName: 'geo-1', configPath: '/c.json' });
    await svc.attachProxy(1, { externalId: 'p', mode: 'local', protocol: 'http' });
    expect(mockProvider.attachProxy).toHaveBeenCalled();
  });

  it('deleteAt 调 provider.delete + status=deleted', async () => {
    repo.findOneBy.mockResolvedValue({ id: 1, provider: 'plain_chromium', sessionName: 'geo-1' });
    await svc.deleteAt(1);
    expect(mockProvider.delete).toHaveBeenCalled();
    expect(repo.update).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'deleted' }));
  });
});
  • Step 2跑测试看失败
cd packages/backend && pnpm jest test/modules/geo/service_browser_profile.test.ts
  • Step 3实现
// packages/backend/src/modules/geo/service/browser_profile.ts
import { Provide, Logger, ILogger } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { GeoBrowserProfileEntity } from '../entity/browser_profile';
import { IBrowserProvider, CreateOpts, ProfileInfo } from '../provider/browser/interface';
import { PlainChromiumProvider } from '../provider/browser/plain_chromium';
import { BitBrowserStubProvider } from '../provider/browser/bitbrowser.stub';
import { AntBrowserStubProvider } from '../provider/browser/ant_browser.stub';
import { AdsPowerStubProvider } from '../provider/browser/adspower.stub';
import { ProxyInfo } from '../provider/proxy/interface';

@Provide()
export class GeoBrowserProfileService extends BaseService {
  @InjectEntityModel(GeoBrowserProfileEntity)
  profileEntity: Repository<GeoBrowserProfileEntity>;

  @Logger()
  logger: ILogger;

  private providers: Map<string, IBrowserProvider> = new Map([
    ['plain_chromium', new PlainChromiumProvider()],
    ['bitbrowser', new BitBrowserStubProvider()],
    ['ant_browser', new AntBrowserStubProvider()],
    ['adspower', new AdsPowerStubProvider()],
  ]);

  getProvider(name: string): IBrowserProvider {
    const p = this.providers.get(name);
    if (!p) throw new Error(`Unknown browser provider: ${name}`);
    return p;
  }

  toProfileInfo(e: GeoBrowserProfileEntity): ProfileInfo {
    return {
      sessionName: e.sessionName,
      profileDir: e.profileDir,
      configPath: e.configPath,
      userAgent: e.userAgent,
      osPlatform: e.osPlatform,
      timezone: e.timezone,
      language: e.language,
      screenW: e.screenW,
      screenH: e.screenH,
    };
  }

  async createAndPersist(provider: string, opts: CreateOpts, manager?: any): Promise<GeoBrowserProfileEntity> {
    const repo = manager ? manager.getRepository(GeoBrowserProfileEntity) : this.profileEntity;
    const info = await this.getProvider(provider).create(opts);
    const entity = repo.create({
      name: opts.name,
      provider,
      sessionName: info.sessionName,
      profileDir: info.profileDir,
      configPath: info.configPath,
      userAgent: info.userAgent,
      osPlatform: info.osPlatform,
      timezone: info.timezone,
      language: info.language,
      screenW: info.screenW,
      screenH: info.screenH,
      fingerprint: opts.fingerprint || null,
      status: 'created',
    });
    return repo.save(entity);
  }

  async open(id: number, url?: string): Promise<void> {
    const e = await this.profileEntity.findOneBy({ id });
    if (!e) throw new Error(`profile ${id} not found`);
    await this.getProvider(e.provider).open(this.toProfileInfo(e), url);
    await this.profileEntity.update(id, { status: 'running', lastOpenAt: new Date() });
  }

  async close(id: number): Promise<void> {
    const e = await this.profileEntity.findOneBy({ id });
    if (!e) return;
    await this.getProvider(e.provider).close(this.toProfileInfo(e)).catch(err =>
      this.logger.warn(`close profile ${id} failed: ${err.message}`)
    );
    await this.profileEntity.update(id, { status: 'closed' });
  }

  async attachProxy(profileId: number, proxy: ProxyInfo): Promise<void> {
    const e = await this.profileEntity.findOneBy({ id: profileId });
    if (!e) throw new Error(`profile ${profileId} not found`);
    await this.getProvider(e.provider).attachProxy(this.toProfileInfo(e), proxy);
  }

  async deleteAt(id: number, manager?: any): Promise<void> {
    const repo = manager ? manager.getRepository(GeoBrowserProfileEntity) : this.profileEntity;
    const e = await repo.findOneBy({ id });
    if (!e) return;
    await this.getProvider(e.provider).delete(this.toProfileInfo(e)).catch(err =>
      this.logger.warn(`delete profile ${id} external failed: ${err.message}`)
    );
    await repo.update(id, { status: 'deleted', accountId: null });
  }
}
  • Step 4跑测试看通过

  • Step 5commit

git add packages/backend/src/modules/geo/service/browser_profile.ts packages/backend/test/modules/geo/service_browser_profile.test.ts
git commit -m "feat(geo): GeoBrowserProfileService"

Task 12GeoAccountService 编排核心

Files:

  • Create: packages/backend/src/modules/geo/service/account.ts

  • Test: packages/backend/test/modules/geo/service_account.test.ts

  • Step 1写测试覆盖add 编排、补偿、launch、captureCookies、rebindIp

// packages/backend/test/modules/geo/service_account.test.ts
import { GeoAccountService } from '../../../src/modules/geo/service/account';

describe('GeoAccountService', () => {
  let svc: GeoAccountService;
  let proxySvc: any;
  let profileSvc: any;
  let automationSvc: any;
  let accountRepo: any;
  let dataSource: any;

  beforeEach(() => {
    proxySvc = {
      acquire: jest.fn().mockResolvedValue({ externalId: 'p1', mode: 'local', protocol: 'http' }),
      persist: jest.fn().mockResolvedValue({ id: 11, status: 'active', externalId: 'p1', mode: 'local', protocol: 'http' }),
      getProvider: jest.fn(() => ({ release: jest.fn() })),
      toProxyInfo: jest.fn().mockReturnValue({ externalId: 'p1', mode: 'local', protocol: 'http' }),
    };
    profileSvc = {
      createAndPersist: jest.fn().mockResolvedValue({ id: 22, sessionName: 'geo-22', profileDir: '/p', configPath: '/c.json' }),
      attachProxy: jest.fn(),
      deleteAt: jest.fn(),
      open: jest.fn(),
      toProfileInfo: jest.fn(o => ({ sessionName: o.sessionName })),
      getProvider: jest.fn(() => ({ delete: jest.fn() })),
    };
    automationSvc = {
      getCookies: jest.fn(),
      saveState: jest.fn(),
    };
    accountRepo = {
      create: (e: any) => e,
      save: jest.fn(async (e) => ({ id: 1, ...e })),
      findOneBy: jest.fn(),
      update: jest.fn(),
    };
    const profileRepo = { findOneBy: jest.fn() };
    dataSource = {
      transaction: jest.fn(async (cb: any) => cb({
        getRepository: () => accountRepo,
        update: accountRepo.update,
        findOne: accountRepo.findOneBy,
        findOneOrFail: accountRepo.findOneBy,
      })),
    };
    svc = new GeoAccountService();
    (svc as any).proxyService = proxySvc;
    (svc as any).profileService = profileSvc;
    (svc as any).browserAutomation = automationSvc;
    (svc as any).accountEntity = accountRepo;
    (svc as any).profileEntity = profileRepo;
    (svc as any).dataSource = dataSource;
    (svc as any).encryptService = {
      encrypt: (s: string) => `enc:${s}`,
      decrypt: (s: string) => s.replace(/^enc:/, ''),
    };
  });

  it('add申请 IP+Profile→挂代理→写 account', async () => {
    const acc = await svc.add({
      name: 'a1', platform: 'xiaohongshu', loginAccount: '13800000000', ipMode: 'local',
    } as any);
    expect(proxySvc.acquire).toHaveBeenCalled();
    expect(profileSvc.createAndPersist).toHaveBeenCalled();
    expect(profileSvc.attachProxy).toHaveBeenCalled();
    expect(accountRepo.save).toHaveBeenCalled();
  });

  it('addprofile 创建失败 → 触发 IP 补偿', async () => {
    profileSvc.createAndPersist.mockRejectedValueOnce(new Error('boom'));
    await expect(svc.add({ name: 'a', platform: 'x', loginAccount: '1', ipMode: 'local' } as any)).rejects.toThrow('boom');
    expect(proxySvc.getProvider).toHaveBeenCalledWith('local');
  });

  it('captureCookiescookies 为空时不写库', async () => {
    accountRepo.findOneBy.mockResolvedValue({ id: 1, browserProfileId: 22 });
    (svc as any).profileEntity.findOneBy.mockResolvedValue({ id: 22, sessionName: 'geo-22', status: 'running' });
    automationSvc.getCookies.mockResolvedValue([]);
    const r = await svc.captureCookies(1);
    expect(r.captured).toBe(0);
    expect(accountRepo.update).not.toHaveBeenCalled();
  });

  it('captureCookies抓到 cookie 后加密存储 + saveState', async () => {
    accountRepo.findOneBy.mockResolvedValue({ id: 1, browserProfileId: 22 });
    (svc as any).profileEntity.findOneBy.mockResolvedValue({ id: 22, sessionName: 'geo-22', status: 'running' });
    automationSvc.getCookies.mockResolvedValue([{ name: 'sid', value: 'abc', domain: '.xhs.com' }]);
    await svc.captureCookies(1, ['xhs.com']);
    expect(automationSvc.getCookies).toHaveBeenCalledWith('geo-22', 'xhs.com');
    expect(accountRepo.update).toHaveBeenCalledWith(1, expect.objectContaining({
      loginStatus: 'logged_in',
      cookies: expect.stringMatching(/^enc:/),
    }));
    expect(automationSvc.saveState).toHaveBeenCalled();
  });

  it('launchprofile.status 设 running', async () => {
    accountRepo.findOneBy.mockResolvedValue({ id: 1, browserProfileId: 22 });
    await svc.launch(1, 'https://example.com');
    expect(profileSvc.open).toHaveBeenCalledWith(22, 'https://example.com');
  });
});
  • Step 2跑测试看失败
cd packages/backend && pnpm jest test/modules/geo/service_account.test.ts
  • Step 3实现
// packages/backend/src/modules/geo/service/account.ts
import { Provide, Inject, Logger, ILogger } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
import { InjectEntityModel, InjectDataSource } from '@midwayjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import * as path from 'path';
import { GeoAccountEntity } from '../entity/account';
import { GeoBrowserProfileEntity } from '../entity/browser_profile';
import { GeoProxyIpEntity } from '../entity/proxy_ip';
import { GeoProxyIpService } from './proxy_ip';
import { GeoBrowserProfileService } from './browser_profile';
import { GeoEncryptService } from './encrypt';
import { BrowserAutomationService } from './browser_automation';

export interface AddAccountDto {
  name: string;
  platform: string;
  loginAccount: string;
  ipMode: 'local' | 'third_party';
  region?: string;
  isp?: string;
  fingerprint?: Record<string, any>;
  browserProvider?: string;
  userAgent?: string;
  timezone?: string;
  language?: string;
  screenW?: number;
  screenH?: number;
}

const STATE_DIR = process.env.GEO_STATE_DIR || path.join(process.cwd(), '.geo-browser', 'states');

@Provide()
export class GeoAccountService extends BaseService {
  @InjectEntityModel(GeoAccountEntity)
  accountEntity: Repository<GeoAccountEntity>;

  @InjectEntityModel(GeoBrowserProfileEntity)
  profileEntity: Repository<GeoBrowserProfileEntity>;

  @InjectDataSource()
  dataSource: DataSource;

  @Inject() proxyService: GeoProxyIpService;
  @Inject() profileService: GeoBrowserProfileService;
  @Inject() encryptService: GeoEncryptService;
  @Inject() browserAutomation: BrowserAutomationService;

  @Logger() logger: ILogger;

  /** 创建账号:原子分配 IP + Profile强 1:1:1 */
  async add(dto: AddAccountDto): Promise<GeoAccountEntity> {
    let acquiredIp: any = null;
    let createdProfile: any = null;
    try {
      return await this.dataSource.transaction(async manager => {
        const ipInfo = await this.proxyService.acquire({
          provider: dto.ipMode === 'local' ? 'local' : 'tianqi',
          region: dto.region, isp: dto.isp,
        });
        acquiredIp = ipInfo;
        const ipEntity = await this.proxyService.persist({ ...ipInfo, name: dto.name + '-ip' }, manager);

        // sessionName 必须唯一;此时还没有 account.id先用临时 nanoid写完 account 后再修正
        const tmpSession = `geo-tmp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
        const profileEntity = await this.profileService.createAndPersist(
          dto.browserProvider || 'plain_chromium',
          {
            name: dto.name + '-profile',
            sessionName: tmpSession,
            userAgent: dto.userAgent,
            osPlatform: 'windows',
            timezone: dto.timezone,
            language: dto.language,
            screenW: dto.screenW, screenH: dto.screenH,
            fingerprint: dto.fingerprint,
          },
          manager,
        );
        createdProfile = profileEntity;

        await this.profileService.attachProxy(profileEntity.id, this.proxyService.toProxyInfo(ipEntity));

        const repo = manager.getRepository(GeoAccountEntity);
        const saved = await repo.save(repo.create({
          name: dto.name,
          platform: dto.platform,
          loginAccount: dto.loginAccount,
          loginStatus: 'never',
          status: 'draft',
          proxyId: ipEntity.id,
          browserProfileId: profileEntity.id,
        }));
        await manager.update(GeoProxyIpEntity, ipEntity.id, { bindAccountId: saved.id });
        await manager.update(GeoBrowserProfileEntity, profileEntity.id, {
          accountId: saved.id,
          sessionName: `geo-${saved.id}`, // 改回稳定 sessionName
        });
        return saved;
      });
    } catch (e: any) {
      if (createdProfile?.sessionName) {
        await this.profileService.getProvider(createdProfile.provider || 'plain_chromium')
          .delete(this.profileService.toProfileInfo(createdProfile))
          .catch(err => this.logger.warn(`补偿删 profile 失败: ${err.message}`));
      }
      if (acquiredIp?.externalId) {
        await this.proxyService.getProvider(acquiredIp.provider || 'local')
          .release(acquiredIp.externalId)
          .catch(err => this.logger.warn(`补偿 release IP 失败: ${err.message}`));
      }
      throw e;
    }
  }

  async launch(id: number, url?: string): Promise<{ sessionName: string }> {
    const acc = await this.accountEntity.findOneBy({ id });
    if (!acc) throw new Error(`account ${id} not found`);
    await this.profileService.open(acc.browserProfileId, url);
    const profile = await this.profileEntity.findOneBy({ id: acc.browserProfileId });
    return { sessionName: profile.sessionName };
  }

  async captureCookies(id: number, domains?: string[]): Promise<{ captured: number }> {
    const acc = await this.accountEntity.findOneBy({ id });
    if (!acc) throw new Error(`account ${id} not found`);
    const profile = await this.profileEntity.findOneBy({ id: acc.browserProfileId });
    if (!profile) throw new Error(`profile ${acc.browserProfileId} not found`);
    if (profile.status !== 'running') throw new Error('Browser not running, call launch first');

    const domain = domains?.[0];
    const cookies = await this.browserAutomation.getCookies(profile.sessionName, domain);
    if (cookies.length === 0) return { captured: 0 };

    const encrypted = this.encryptService.encrypt(JSON.stringify(cookies));
    await this.accountEntity.update(id, {
      cookies: encrypted,
      cookieCapturedAt: new Date(),
      loginStatus: 'logged_in',
    });

    const statePath = path.join(STATE_DIR, `geo-state-${id}.json`);
    await this.browserAutomation.saveState(profile.sessionName, statePath).catch(e =>
      this.logger.warn(`saveState failed for ${id}: ${e.message}`)
    );
    return { captured: cookies.length };
  }

  async rebindIp(accountId: number, newIpId: number): Promise<void> {
    await this.dataSource.transaction(async manager => {
      const acc = await manager.findOneOrFail(GeoAccountEntity, { where: { id: accountId } });
      const newIp = await manager.findOne(GeoProxyIpEntity, { where: { id: newIpId } });
      if (!newIp) throw new Error(`IP ${newIpId} not found`);
      if (newIp.bindAccountId && newIp.bindAccountId !== accountId) {
        throw new Error(`IP ${newIpId} 已绑定其他账号`);
      }
      if (acc.proxyId) {
        await manager.update(GeoProxyIpEntity, acc.proxyId, { bindAccountId: null, status: 'unbound' });
      }
      await manager.update(GeoProxyIpEntity, newIpId, { bindAccountId: accountId, status: 'active' });
      await manager.update(GeoAccountEntity, accountId, { proxyId: newIpId });

      const profile = await manager.findOne(GeoBrowserProfileEntity, { where: { id: acc.browserProfileId } });
      if (profile) {
        await this.profileService.attachProxy(profile.id, this.proxyService.toProxyInfo(newIp));
      }
    });
  }

  async deleteAccount(id: number): Promise<void> {
    await this.dataSource.transaction(async manager => {
      const acc = await manager.findOneOrFail(GeoAccountEntity, { where: { id } });
      if (acc.browserProfileId) {
        await this.profileService.deleteAt(acc.browserProfileId, manager);
      }
      if (acc.proxyId) {
        await manager.update(GeoProxyIpEntity, acc.proxyId, { bindAccountId: null, status: 'unbound' });
      }
      await manager.update(GeoAccountEntity, id, { status: 'deleted', proxyId: null, browserProfileId: null });
    });
  }
}
  • Step 4跑测试看通过
cd packages/backend && pnpm jest test/modules/geo/service_account.test.ts
  • Step 5commit
git add packages/backend/src/modules/geo/service/account.ts packages/backend/test/modules/geo/service_account.test.ts
git commit -m "feat(geo): GeoAccountService 编排(依赖 BrowserAutomationService"

Task 13ProxyIp Controller

Files:

  • Create: packages/backend/src/modules/geo/controller/admin/proxy_ip.ts

  • Step 1实现

// packages/backend/src/modules/geo/controller/admin/proxy_ip.ts
import { Provide, Inject, Post, Body } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { GeoProxyIpEntity } from '../../entity/proxy_ip';
import { GeoProxyIpService } from '../../service/proxy_ip';

@Provide()
@CoolController({
  api: ['add', 'delete', 'update', 'info', 'list', 'page'],
  entity: GeoProxyIpEntity,
  service: GeoProxyIpService,
  pageQueryOp: {
    keyWordLikeFields: ['name', 'host', 'region'],
    fieldEq: ['provider', 'mode', 'status'],
    addOrderBy: { createTime: 'DESC' },
  },
})
export class AdminGeoProxyIpController extends BaseController {
  @Inject() proxyIpService: GeoProxyIpService;

  @Post('/healthCheck', { summary: '触发单条 IP 健康检查' })
  async healthCheck(@Body('id') id: number) {
    const ip = await this.proxyIpService.proxyIpEntity.findOneBy({ id });
    if (!ip) return this.fail('IP 不存在');
    const result = await this.proxyIpService.getProvider(ip.provider).healthCheck(this.proxyIpService.toProxyInfo(ip));
    await this.proxyIpService.proxyIpEntity.update(id, {
      latencyMs: result.latencyMs, lastCheckAt: new Date(),
      status: result.ok ? 'active' : 'error',
    });
    return this.ok(result);
  }

  @Post('/healthCheckAll', { summary: '批量健康检查' })
  async healthCheckAll() {
    await this.proxyIpService.healthCheckAll();
    return this.ok();
  }
}
  • Step 2启动后端验证 CRUD 路由
cd packages/backend && pnpm dev
curl -X POST http://localhost:8003/admin/geo/proxy_ip/page -H 'Content-Type: application/json' -d '{"page":1,"size":10}'

预期200。

  • Step 3commit
git add packages/backend/src/modules/geo/controller/admin/proxy_ip.ts
git commit -m "feat(geo): IP 池 Controller"

Task 14BrowserProfile Controller

Files:

  • Create: packages/backend/src/modules/geo/controller/admin/browser_profile.ts

  • Step 1实现

// packages/backend/src/modules/geo/controller/admin/browser_profile.ts
import { Provide, Inject, Post, Body } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { GeoBrowserProfileEntity } from '../../entity/browser_profile';
import { GeoBrowserProfileService } from '../../service/browser_profile';

@Provide()
@CoolController({
  api: ['add', 'delete', 'update', 'info', 'list', 'page'],
  entity: GeoBrowserProfileEntity,
  service: GeoBrowserProfileService,
  pageQueryOp: {
    keyWordLikeFields: ['name', 'sessionName'],
    fieldEq: ['provider', 'status'],
    addOrderBy: { createTime: 'DESC' },
  },
})
export class AdminGeoBrowserProfileController extends BaseController {
  @Inject() profileService: GeoBrowserProfileService;

  @Post('/open', { summary: '启动浏览器' })
  async open(@Body('id') id: number, @Body('url') url?: string) {
    await this.profileService.open(id, url);
    return this.ok();
  }

  @Post('/close', { summary: '关闭浏览器' })
  async close(@Body('id') id: number) {
    await this.profileService.close(id);
    return this.ok();
  }
}
  • Step 2启动验证

  • Step 3commit

git add packages/backend/src/modules/geo/controller/admin/browser_profile.ts
git commit -m "feat(geo): 指纹浏览器 Controller"

Task 15Account Controller

Files:

  • Create: packages/backend/src/modules/geo/controller/admin/account.ts

  • Step 1实现

// packages/backend/src/modules/geo/controller/admin/account.ts
import { Provide, Inject, Post, Body } from '@midwayjs/core';
import { CoolController, BaseController } from '@cool-midway/core';
import { GeoAccountEntity } from '../../entity/account';
import { GeoAccountService } from '../../service/account';

@Provide()
@CoolController({
  api: ['delete', 'update', 'info', 'list', 'page'],
  entity: GeoAccountEntity,
  service: GeoAccountService,
  pageQueryOp: {
    keyWordLikeFields: ['name', 'loginAccount'],
    fieldEq: ['platform', 'status', 'loginStatus'],
    addOrderBy: { createTime: 'DESC' },
  },
})
export class AdminGeoAccountController extends BaseController {
  @Inject() accountService: GeoAccountService;

  @Post('/add', { summary: '创建账号(同时分配 IP+Profile' })
  async addAccount(@Body() dto: any) {
    return this.ok(await this.accountService.add(dto));
  }

  @Post('/launch', { summary: '启动浏览器登录' })
  async launch(@Body('id') id: number, @Body('url') url?: string) {
    return this.ok(await this.accountService.launch(id, url));
  }

  @Post('/captureCookies', { summary: '抓 cookie' })
  async captureCookies(@Body('id') id: number, @Body('domains') domains?: string[]) {
    return this.ok(await this.accountService.captureCookies(id, domains));
  }

  @Post('/rebindIp', { summary: '重新绑定 IP' })
  async rebindIp(@Body('id') id: number, @Body('newIpId') newIpId: number) {
    await this.accountService.rebindIp(id, newIpId);
    return this.ok();
  }

  @Post('/deleteAccount', { summary: '逻辑删除账号' })
  async deleteAccount(@Body('id') id: number) {
    await this.accountService.deleteAccount(id);
    return this.ok();
  }
}
  • Step 2启动验证

  • Step 3commit

git add packages/backend/src/modules/geo/controller/admin/account.ts
git commit -m "feat(geo): 账号 Controller"

Task 16注入 base_sys_menu 菜单

通过 MCP mcp__mysql__execute 直接 INSERT。

  • Step 1连接数据库(如未连接)

  • Step 2插入 GEO 一级目录

INSERT INTO base_sys_menu (parentId, name, icon, orderNum, type, isShow, createTime, updateTime)
VALUES (NULL, '🌍 GEO', 'icon-trend', 50, 0, 1, NOW(), NOW());

查 id

SELECT id FROM base_sys_menu WHERE name = '🌍 GEO' ORDER BY id DESC LIMIT 1;

记为 <GEO_ROOT_ID>

  • Step 33 个二级菜单
INSERT INTO base_sys_menu (parentId, name, router, viewPath, orderNum, type, isShow, createTime, updateTime) VALUES
 (<GEO_ROOT_ID>, '账号矩阵', '/geo/accounts', 'modules/geo/views/accounts.vue', 1, 1, 1, NOW(), NOW()),
 (<GEO_ROOT_ID>, 'IP 池', '/geo/proxies', 'modules/geo/views/proxies.vue', 2, 1, 1, NOW(), NOW()),
 (<GEO_ROOT_ID>, '指纹浏览器', '/geo/browser-profiles', 'modules/geo/views/browser-profiles.vue', 3, 1, 1, NOW(), NOW());

记下 3 个 id 为 <ACCOUNTS_ID> <PROXIES_ID> <PROFILES_ID>

  • Step 414 条按钮权限
INSERT INTO base_sys_menu (parentId, type, perms, createTime, updateTime) VALUES
 (<ACCOUNTS_ID>, 2, 'geo:account:add', NOW(), NOW()),
 (<ACCOUNTS_ID>, 2, 'geo:account:update', NOW(), NOW()),
 (<ACCOUNTS_ID>, 2, 'geo:account:delete', NOW(), NOW()),
 (<ACCOUNTS_ID>, 2, 'geo:account:launch', NOW(), NOW()),
 (<ACCOUNTS_ID>, 2, 'geo:account:captureCookies', NOW(), NOW()),
 (<ACCOUNTS_ID>, 2, 'geo:account:rebindIp', NOW(), NOW()),
 (<PROXIES_ID>, 2, 'geo:proxy:add', NOW(), NOW()),
 (<PROXIES_ID>, 2, 'geo:proxy:update', NOW(), NOW()),
 (<PROXIES_ID>, 2, 'geo:proxy:delete', NOW(), NOW()),
 (<PROXIES_ID>, 2, 'geo:proxy:healthCheck', NOW(), NOW()),
 (<PROFILES_ID>, 2, 'geo:profile:add', NOW(), NOW()),
 (<PROFILES_ID>, 2, 'geo:profile:update', NOW(), NOW()),
 (<PROFILES_ID>, 2, 'geo:profile:delete', NOW(), NOW()),
 (<PROFILES_ID>, 2, 'geo:profile:open', NOW(), NOW());
  • Step 5验证
SELECT id, parentId, name, type, perms FROM base_sys_menu WHERE name LIKE '%GEO%' OR perms LIKE 'geo:%' ORDER BY id;

预期1 + 3 + 14。


Task 17注册定时任务

通过 MCP mcp__mysql__execute

  • Step 1插入 cron 任务
INSERT INTO task_info (name, type, cron, service, status, taskType, createTime, updateTime)
VALUES (
  'GEO IP 健康检查',
  0,
  '0 0 */6 * * *',
  'geoProxyIpService.healthCheckAll',
  1,
  'local',
  NOW(), NOW()
);

如 service 字段格式与 task_info 现有约定不符,参考:SELECT name, service FROM task_info WHERE status = 1 LIMIT 5; 调整。

  • Step 2启动后端检查日志
cd packages/backend && pnpm dev

预期task 模块加载这条 cron。Ctrl+C 停止。


Task 18前端 config + dashboard

Files:

  • Create: packages/frontend/src/modules/geo/config.ts

  • Create: packages/frontend/src/modules/geo/views/dashboard.vue

  • Step 1config.ts

import { ModuleConfig } from '/$/cool';

export default (): ModuleConfig => ({
  name: 'geo',
  label: 'GEO 生成式引擎优化',
  order: 60,
  views: [
    { path: '/geo/dashboard', meta: { label: 'GEO 总览' }, component: () => import('./views/dashboard.vue') },
    { path: '/geo/accounts', meta: { label: '账号矩阵' }, component: () => import('./views/accounts.vue') },
    { path: '/geo/proxies', meta: { label: 'IP 池' }, component: () => import('./views/proxies.vue') },
    { path: '/geo/browser-profiles', meta: { label: '指纹浏览器' }, component: () => import('./views/browser-profiles.vue') },
  ],
});
  • Step 2dashboard.vue 占位
<template>
  <div class="geo-dashboard">
    <el-card>
      <el-empty description="GEO 总览S4 阶段补充:覆盖率/引用率/趋势)" />
    </el-card>
  </div>
</template>
<script lang="ts" setup></script>
<style lang="scss" scoped>.geo-dashboard { padding: 16px; }</style>
  • Step 3commit
git add packages/frontend/src/modules/geo/config.ts packages/frontend/src/modules/geo/views/dashboard.vue
git commit -m "feat(geo)[fe]: 前端模块 config + dashboard 占位"

Task 19前端 IP 池页面

Files:

  • Create: packages/frontend/src/modules/geo/views/proxies.vue

  • Step 1实现(与原 plan 一致,无浏览器层耦合)

<template>
  <cl-crud ref="Crud">
    <cl-row><cl-refresh-btn /><cl-add-btn /><cl-multi-delete-btn /><cl-flex1 /><cl-search-key /></cl-row>
    <cl-row><cl-table v-bind="table" /></cl-row>
    <cl-row><cl-flex1 /><cl-pagination /></cl-row>
    <cl-upsert ref="Upsert" v-bind="upsert" />
  </cl-crud>
</template>
<script lang="ts" setup>
import { useCool, useCrud, useTable, useUpsert } from '/$/cool';
import { ElMessage } from 'element-plus';
const { service } = useCool();
const Upsert = useUpsert({
  items: [
    { prop: 'name', label: '名称', component: { name: 'el-input' }, rules: { required: true } },
    { prop: 'provider', label: 'Provider', component: { name: 'el-select', options: [{ label: 'local 本地', value: 'local' }, { label: 'tianqi 天启', value: 'tianqi' }] }, rules: { required: true } },
    { prop: 'mode', label: '模式', component: { name: 'el-select', options: [{ label: '本地', value: 'local' }, { label: '第三方', value: 'third_party' }] }, rules: { required: true } },
    { prop: 'host', label: '主机' },
    { prop: 'port', label: '端口', component: { name: 'el-input-number' } },
    { prop: 'protocol', label: '协议', component: { name: 'el-select', options: [{ label: 'http', value: 'http' }, { label: 'socks5', value: 'socks5' }] }, value: 'http' },
    { prop: 'region', label: '区域' },
    { prop: 'isp', label: 'ISP' },
  ],
});
const Crud = useCrud({ service: service.geo.proxy_ip }, (app) => app.refresh());
const Table = useTable({
  columns: [
    { type: 'selection' },
    { prop: 'id', label: 'ID', width: 80 },
    { prop: 'name', label: '名称' },
    { prop: 'provider', label: 'Provider', width: 100 },
    { prop: 'mode', label: '模式', width: 100 },
    { prop: 'host', label: '主机' },
    { prop: 'port', label: '端口', width: 80 },
    { prop: 'region', label: '区域', width: 100 },
    { prop: 'status', label: '状态', width: 100 },
    { prop: 'latencyMs', label: '延迟(ms)', width: 100 },
    { prop: 'lastCheckAt', label: '上次检查', width: 160 },
    { label: '操作', type: 'op', buttons: [
      { label: '健康检查', type: 'primary', onClick({ scope }) { healthCheck(scope.row.id); } },
      'edit', 'delete',
    ]},
  ],
});
async function healthCheck(id: number) {
  const r: any = await service.request({ url: '/admin/geo/proxy_ip/healthCheck', method: 'POST', data: { id } });
  ElMessage.success(`延迟 ${r.latencyMs}ms / ${r.ok ? 'OK' : 'FAIL'}`);
  (Crud.value as any).refresh();
}
</script>
  • Step 2启动前端验证

打开 http://localhost:9001/geo/proxies

  • Step 3commit
git add packages/frontend/src/modules/geo/views/proxies.vue
git commit -m "feat(geo)[fe]: IP 池管理页"

Task 20前端指纹浏览器页面

Files:

  • Create: packages/frontend/src/modules/geo/views/browser-profiles.vue

  • Step 1实现

<template>
  <cl-crud ref="Crud">
    <cl-row><cl-refresh-btn /><cl-add-btn /><cl-multi-delete-btn /><cl-flex1 /><cl-search-key /></cl-row>
    <cl-row><cl-table v-bind="table" /></cl-row>
    <cl-row><cl-flex1 /><cl-pagination /></cl-row>
    <cl-upsert ref="Upsert" v-bind="upsert" />
  </cl-crud>
</template>
<script lang="ts" setup>
import { useCool, useCrud, useTable, useUpsert } from '/$/cool';
import { ElMessage, ElMessageBox } from 'element-plus';
const { service } = useCool();
const Upsert = useUpsert({
  items: [
    { prop: 'name', label: '名称', component: { name: 'el-input' }, rules: { required: true } },
    { prop: 'provider', label: 'Provider', component: { name: 'el-select', options: [
      { label: 'Plain Chromium默认', value: 'plain_chromium' },
      { label: 'BitBrowser (占位)', value: 'bitbrowser' },
      { label: 'ant-browser (占位)', value: 'ant_browser' },
      { label: 'AdsPower (占位)', value: 'adspower' },
    ] }, value: 'plain_chromium', rules: { required: true } },
    { prop: 'sessionName', label: 'Session Name', component: { name: 'el-input' }, rules: { required: true } },
    { prop: 'userAgent', label: 'User Agent' },
    { prop: 'osPlatform', label: 'OS', component: { name: 'el-select', options: [{ label: 'windows', value: 'windows' }, { label: 'mac', value: 'mac' }, { label: 'linux', value: 'linux' }] }, value: 'windows' },
    { prop: 'timezone', label: '时区', value: 'Asia/Shanghai' },
    { prop: 'language', label: '语言', value: 'zh-CN' },
    { prop: 'screenW', label: '屏宽', component: { name: 'el-input-number' }, value: 1920 },
    { prop: 'screenH', label: '屏高', component: { name: 'el-input-number' }, value: 1080 },
  ],
});
const Crud = useCrud({ service: service.geo.browser_profile }, (app) => app.refresh());
const Table = useTable({
  columns: [
    { type: 'selection' },
    { prop: 'id', label: 'ID', width: 80 },
    { prop: 'name', label: '名称' },
    { prop: 'provider', label: 'Provider', width: 130 },
    { prop: 'sessionName', label: 'Session' },
    { prop: 'osPlatform', label: 'OS', width: 80 },
    { prop: 'status', label: '状态', width: 100 },
    { prop: 'lastOpenAt', label: '上次打开', width: 160 },
    { label: '操作', type: 'op', buttons: [
      { label: '打开', type: 'primary', onClick({ scope }) { openProfile(scope.row.id); } },
      { label: '关闭', onClick({ scope }) { closeProfile(scope.row.id); } },
      'edit', 'delete',
    ]},
  ],
});
async function openProfile(id: number) {
  try {
    await service.request({ url: '/admin/geo/browser_profile/open', method: 'POST', data: { id } });
    ElMessage.success('已启动 Chromium 窗口');
    (Crud.value as any).refresh();
  } catch (e: any) {
    if (String(e.message || '').includes('BrowserProviderUnavailable')) {
      ElMessageBox.alert('playwright-cli 未安装或不可用请先安装npm i -g @playwright/cli@latest', '提示');
    } else {
      ElMessage.error(e.message);
    }
  }
}
async function closeProfile(id: number) {
  await service.request({ url: '/admin/geo/browser_profile/close', method: 'POST', data: { id } });
  ElMessage.success('已关闭');
  (Crud.value as any).refresh();
}
</script>
  • Step 2启动前端验证

  • Step 3commit

git add packages/frontend/src/modules/geo/views/browser-profiles.vue
git commit -m "feat(geo)[fe]: 指纹浏览器管理页"

Task 21前端账号矩阵页面

Files:

  • Create: packages/frontend/src/modules/geo/views/accounts.vue

  • Step 1实现

<template>
  <cl-crud ref="Crud">
    <cl-row>
      <cl-refresh-btn />
      <el-button type="primary" @click="openAdd">新增账号</el-button>
      <cl-flex1 />
      <cl-search-key />
    </cl-row>
    <cl-row><cl-table v-bind="table" /></cl-row>
    <cl-row><cl-flex1 /><cl-pagination /></cl-row>

    <el-dialog v-model="addDlg" title="新增账号(自动分配 IP + 浏览器 Profile" width="640">
      <el-form :model="form" label-width="120px">
        <el-form-item label="账号名称"><el-input v-model="form.name" /></el-form-item>
        <el-form-item label="平台">
          <el-select v-model="form.platform">
            <el-option label="小红书" value="xiaohongshu" />
            <el-option label="抖音" value="douyin" />
            <el-option label="微博" value="weibo" />
            <el-option label="知乎" value="zhihu" />
            <el-option label="微信" value="wechat" />
          </el-select>
        </el-form-item>
        <el-form-item label="登录账号"><el-input v-model="form.loginAccount" placeholder="手机号或账号" /></el-form-item>
        <el-form-item label="IP 模式">
          <el-radio-group v-model="form.ipMode">
            <el-radio value="local">本地 IP</el-radio>
            <el-radio value="third_party">第三方天启</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="区域" v-if="form.ipMode === 'third_party'"><el-input v-model="form.region" /></el-form-item>
        <el-form-item label="浏览器 Provider">
          <el-select v-model="form.browserProvider">
            <el-option label="Plain Chromium推荐" value="plain_chromium" />
            <el-option label="BitBrowser (占位)" value="bitbrowser" />
            <el-option label="ant-browser (占位)" value="ant_browser" />
          </el-select>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="addDlg = false">取消</el-button>
        <el-button type="primary" :loading="adding" @click="submitAdd">创建</el-button>
      </template>
    </el-dialog>
  </cl-crud>
</template>

<script lang="ts" setup>
import { ref, reactive } from 'vue';
import { useCool, useCrud, useTable } from '/$/cool';
import { ElMessage, ElMessageBox } from 'element-plus';
const { service } = useCool();
const Crud = useCrud({ service: service.geo.account }, (app) => app.refresh());
const Table = useTable({
  columns: [
    { prop: 'id', label: 'ID', width: 80 },
    { prop: 'name', label: '名称' },
    { prop: 'platform', label: '平台', width: 110 },
    { prop: 'loginAccount', label: '登录账号' },
    { prop: 'loginStatus', label: '登录状态', width: 110, dict: [
      { label: '未登录', value: 'never', color: 'info' },
      { label: '已登录', value: 'logged_in', color: 'success' },
      { label: '已过期', value: 'expired', color: 'warning' },
    ]},
    { prop: 'status', label: '状态', width: 100 },
    { prop: 'proxyId', label: 'IP', width: 80 },
    { prop: 'browserProfileId', label: 'Profile', width: 80 },
    { prop: 'cookieCapturedAt', label: '抓 Cookie 时间', width: 160 },
    { label: '操作', type: 'op', width: 320, buttons: [
      { label: '启动登录', type: 'primary', onClick({ scope }) { launch(scope.row.id, scope.row.platform); } },
      { label: '抓 Cookie', onClick({ scope }) { captureCookies(scope.row.id, scope.row.platform); } },
      { label: '删除', type: 'danger', onClick({ scope }) { remove(scope.row.id); } },
    ]},
  ],
});

const PLATFORM_URLS: Record<string, string> = {
  xiaohongshu: 'https://www.xiaohongshu.com',
  douyin: 'https://www.douyin.com',
  weibo: 'https://weibo.com',
  zhihu: 'https://www.zhihu.com',
  wechat: 'https://mp.weixin.qq.com',
};
const PLATFORM_DOMAINS: Record<string, string[]> = {
  xiaohongshu: ['xiaohongshu.com'],
  douyin: ['douyin.com'],
  weibo: ['weibo.com'],
  zhihu: ['zhihu.com'],
  wechat: ['weixin.qq.com'],
};

const addDlg = ref(false);
const adding = ref(false);
const form = reactive({
  name: '', platform: 'xiaohongshu', loginAccount: '',
  ipMode: 'local' as 'local' | 'third_party',
  region: '', browserProvider: 'plain_chromium',
});
function openAdd() {
  Object.assign(form, { name: '', platform: 'xiaohongshu', loginAccount: '', ipMode: 'local', region: '', browserProvider: 'plain_chromium' });
  addDlg.value = true;
}
async function submitAdd() {
  if (!form.name || !form.loginAccount) {
    ElMessage.warning('账号名称、登录账号必填');
    return;
  }
  adding.value = true;
  try {
    await service.request({ url: '/admin/geo/account/add', method: 'POST', data: form });
    addDlg.value = false;
    ElMessage.success('已创建');
    (Crud.value as any).refresh();
  } catch (e: any) {
    if (String(e.message || '').includes('BrowserProviderUnavailable')) {
      ElMessageBox.alert('playwright-cli 未安装。请先安装npm i -g @playwright/cli@latest', '提示');
    } else {
      ElMessage.error(e.message);
    }
  } finally {
    adding.value = false;
  }
}
async function launch(id: number, platform: string) {
  try {
    const url = PLATFORM_URLS[platform];
    const r: any = await service.request({ url: '/admin/geo/account/launch', method: 'POST', data: { id, url } });
    ElMessageBox.alert(`Chromium 已启动并打开 ${platform} 主页。请在窗口里登录,然后回来点"抓 Cookie"。\n\nSession: ${r.sessionName}`, '提示');
  } catch (e: any) {
    ElMessage.error(e.message);
  }
}
async function captureCookies(id: number, platform: string) {
  const domains = PLATFORM_DOMAINS[platform];
  const r: any = await service.request({ url: '/admin/geo/account/captureCookies', method: 'POST', data: { id, domains } });
  if (r.captured === 0) {
    ElMessageBox.alert('未抓到目标域 cookie请确认已在浏览器里完成登录。', '提示');
  } else {
    ElMessage.success(`已抓到 ${r.captured} 条 cookie`);
    (Crud.value as any).refresh();
  }
}
async function remove(id: number) {
  await ElMessageBox.confirm('确认删除该账号?将释放 IP 并删除浏览器 Profile。', '提示', { type: 'warning' });
  await service.request({ url: '/admin/geo/account/deleteAccount', method: 'POST', data: { id } });
  ElMessage.success('已删除');
  (Crud.value as any).refresh();
}
</script>
  • Step 2启动前端验证

  • Step 3commit

git add packages/frontend/src/modules/geo/views/accounts.vue
git commit -m "feat(geo)[fe]: 账号矩阵管理页"

Task 22联调与冒烟手工验证

  • Step 1playwright-cli 自检
playwright-cli --version

失败 → npm i -g @playwright/cli@latest

  • Step 2双端启动
cd packages/backend && pnpm dev   # 终端 1
cd packages/frontend && pnpm dev  # 终端 2
  • Step 3MCP mysql 验证三张表
mcp__mysql__list_tables → 应包含 geo_account / geo_proxy_ip / geo_browser_profile
  • Step 4MCP mysql 验证菜单
SELECT id, parentId, name, type, perms FROM base_sys_menu WHERE name LIKE '%GEO%' OR perms LIKE 'geo:%';

预期1 + 3 + 14。

  • Step 5浏览器登录 admin 验证菜单

打开 http://localhost:9001admin 登录。左侧应出现 🌍 GEO

  • Step 6本地 IP 流程

进入 IP 池 → 新增 → name=test-local / provider=local / mode=local → 创建 → 健康检查 → 应返回延迟。

  • Step 7账号完整闭环关键

进入 账号矩阵 → 新增账号 → name=xhs-test / 平台=xiaohongshu / loginAccount=13800001234 / IP 模式=local / 浏览器 Provider=plain_chromium → 创建。

预期:

  • 列表新行 proxyId / browserProfileId 都有值
  • 数据库 geo_proxy_ip 对应记录 bindAccountId 写了
  • geo_browser_profile 对应记录 accountId 写了sessionName=geo-{id}

点击「启动登录」→ Chromium 弹真实窗口(已打开小红书首页)→ 在窗口里扫码登录。

回控制台点击「抓 Cookie」→ 提示"已抓到 N 条 cookie" → 列表刷新loginStatus 变 logged_in。

数据库验证:

SELECT id, login_status, LENGTH(cookies) AS cookie_len, cookie_captured_at
FROM geo_account WHERE platform='xiaohongshu' ORDER BY id DESC LIMIT 1;

预期cookie_len > 100login_status='logged_in'。

  • Step 8删除清理

点击「删除」→ 确认。

SELECT id, status FROM geo_account WHERE id = <ID>;
SELECT id, status FROM geo_proxy_ip WHERE id = <PROXY_ID>;
SELECT id, status FROM geo_browser_profile WHERE id = <PROFILE_ID>;

预期account=deleted、proxy=unbound、profile=deleted。

  • Step 9强绑定约束
UPDATE geo_proxy_ip SET bind_account_id = 9999 WHERE id = <已绑 IP id>;

预期:唯一索引报错。

  • Step 10playwright-cli 不可用降级

临时改环境变量 PATH 让 playwright-cli 失踪 → 前端再点「打开」profile。 预期:弹窗"playwright-cli 未安装"。

  • Step 11所有 geo 测试
cd packages/backend && pnpm jest test/modules/geo/

预期:全部 PASS。

  • Step 12tsc 类型检查
cd packages/backend && npx tsc --noEmit
cd packages/frontend && pnpm type-check
  • Step 13更新路线图 + commit

docs/superpowers/specs/2026-05-03-geo-master-roadmap.md §11 的 S1 状态改为 已完成。

git add docs/superpowers/specs/2026-05-03-geo-master-roadmap.md
git commit -m "docs(geo): 更新路线图 S1 状态为已完成"

验收清单(与 spec §10 对齐)

  • 后端启动表自动创建Step 3
  • Cool Admin 自动 CRUD 全部可用Task 13-15 各自验证)
  • 自定义接口可用launch / captureCookies / rebindIp / healthCheck / open / closeStep 7
  • base_sys_menu 已注入Step 4
  • 前端菜单可见Step 5
  • 完整闭环跑通Step 6-7
  • 删除安全释放Step 8
  • 强绑定约束生效Step 9
  • playwright-cli 不可用友好降级Step 10

实施期间约束

  • 不写 SQL 文件(用 MCP mysql 直接 INSERT
  • 不手动改 entities.tsdev 模式通配符扫描prod build 由 cool entity 生成)
  • 不引入 gateway/skill/runtimeS3 之后再考虑)
  • 不实现平台特定登录流程QR/账密/短信全部 S3
  • 不在 controller 写业务逻辑
  • 不绕过 Cool Admin 自动 CRUD 写手工 axios
  • 不在 BrowserProvider 里做 cookie/state 操作(那是 BrowserAutomationService 的职责)

实施完成后

  • 调用 superpowers:verification-before-completion 逐条核对验收清单
  • 调用 superpowers:requesting-code-review 触发代码审查
  • 调用 superpowers:finishing-a-development-branch 决策合并方式
  • 更新 ../specs/2026-05-03-geo-master-roadmap.md §11
  • 进入 S2关键词与知识图谱的 brainstorming 流程