# 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-master-roadmap.md) · [`../specs/2026-05-03-geo-s1-infrastructure-design.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 + Chromium,BitBrowser/AntBrowser/AdsPower 全 stub),`BrowserAutomationService` 统一用 `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-cli(Neta 已有 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` | BrowserAutomationService(mock 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` | PlainChromiumProvider(mock 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_menu`:1 条目录 + 3 条菜单页面 + 14 条按钮权限 - `task_info`:1 条 cron 记录(`geo.proxy_ip.healthCheckAll` 每 6 小时) --- ## 依赖检查 实施前确认: - `pnpm` 已安装 - 数据库 `neta_test` 可连通(`packages/backend/src/config/config.local.ts`) - `playwright-cli` 全局可用(`npx --no-install playwright-cli --version` 或 `npm i -g @playwright/cli@latest`) - Neta 已有 `packages/backend/skills/playwright-cli/SKILL.md` --- ## Task 0:geo 模块脚手架 **Files:** - Create: `packages/backend/src/modules/geo/config.ts` - [ ] **Step 1:创建目录骨架** ```bash 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** ```typescript // 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:启动后端验证模块加载** ```bash cd packages/backend && pnpm dev ``` 预期:日志中 `GEO 生成式引擎优化` 模块被加载,无报错。Ctrl+C 停止。 - [ ] **Step 4:commit** ```bash git add packages/backend/src/modules/geo/config.ts git commit -m "feat(geo): 新建 geo 模块脚手架(S1)" ``` --- ## Task 1:GeoEncryptService **Files:** - Create: `packages/backend/src/modules/geo/service/encrypt.ts` - Test: `packages/backend/test/modules/geo/encrypt.test.ts` - [ ] **Step 1:写测试** ```typescript // 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:跑测试看失败** ```bash cd packages/backend && pnpm jest test/modules/geo/encrypt.test.ts ``` 预期:FAIL - [ ] **Step 3:实现** ```typescript // 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:跑测试看通过** ```bash cd packages/backend && pnpm jest test/modules/geo/encrypt.test.ts ``` 预期:PASS(4 tests) - [ ] **Step 5:commit** ```bash git add packages/backend/src/modules/geo/service/encrypt.ts packages/backend/test/modules/geo/encrypt.test.ts git commit -m "feat(geo): 加密服务 GeoEncryptService(AES-256-GCM)" ``` --- ## Task 2:BrowserAutomationService(统一自动化层) > **关键设计**:这是 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:写测试** ```typescript // 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:跑测试看失败** ```bash cd packages/backend && pnpm jest test/modules/geo/browser_automation.test.ts ``` 预期:FAIL - [ ] **Step 3:实现** ```typescript // 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 }); } /** 列出 cookie;domain 可选 */ async getCookies(sessionName: string, domain?: string): Promise { 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 { 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 { 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:跑测试看通过** ```bash cd packages/backend && pnpm jest test/modules/geo/browser_automation.test.ts ``` 预期:PASS(6 tests) - [ ] **Step 5:commit** ```bash 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 3:Provider 接口定义 **Files:** - Create: `packages/backend/src/modules/geo/provider/proxy/interface.ts` - Create: `packages/backend/src/modules/geo/provider/browser/interface.ts` - [ ] **Step 1:IProxyProvider** ```typescript // packages/backend/src/modules/geo/provider/proxy/interface.ts export interface AcquireOpts { region?: string; isp?: string; duration?: 'fixed' | 'rotating'; extra?: Record; } 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; release(externalId: string): Promise; healthCheck(p: ProxyInfo): Promise; list?(): Promise; } ``` - [ ] **Step 2:IBrowserProvider(仅进程生命周期,不含自动化)** ```typescript // 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; } 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; /** 删除 profile 配置 */ delete(profile: ProfileInfo): Promise; /** 把代理写入 profile 配置 */ attachProxy(profile: ProfileInfo, proxy: ProxyInfo): Promise; /** 启动浏览器进程并打开 URL */ open(profile: ProfileInfo, url?: string): Promise; /** 关闭浏览器进程 */ close(profile: ProfileInfo): Promise; } ``` - [ ] **Step 3:tsc 类型检查** ```bash cd packages/backend && npx tsc --noEmit ``` 预期:通过 - [ ] **Step 4:commit** ```bash git add packages/backend/src/modules/geo/provider/ git commit -m "feat(geo): IProxyProvider 与 IBrowserProvider 接口(自动化已剥离到 BrowserAutomationService)" ``` --- ## Task 4:LocalProxyProvider **Files:** - Create: `packages/backend/src/modules/geo/provider/proxy/local.ts` - Test: `packages/backend/test/modules/geo/proxy_provider_local.test.ts` - [ ] **Step 1:写测试** ```typescript // 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:跑测试看失败** ```bash cd packages/backend && pnpm jest test/modules/geo/proxy_provider_local.test.ts ``` 预期:FAIL - [ ] **Step 3:实现** ```typescript // 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 { 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 {} async healthCheck(_p: ProxyInfo): Promise { 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:跑测试看通过** ```bash cd packages/backend && pnpm jest test/modules/geo/proxy_provider_local.test.ts ``` 预期:PASS(4 tests) - [ ] **Step 5:commit** ```bash 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:占位 Provider(Tianqi + 浏览器三个 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:写测试** ```typescript // 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/); }); }); ``` ```typescript // 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:跑测试看失败** ```bash 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 个占位** ```typescript // 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 { throw new Error('NotImplemented: TianqiProxyProvider 等待对接文档'); } async release(_externalId: string): Promise { throw new Error('NotImplemented: TianqiProxyProvider 等待对接文档'); } async healthCheck(_p: ProxyInfo): Promise { throw new Error('NotImplemented: TianqiProxyProvider 等待对接文档'); } } ``` ```typescript // packages/backend/src/modules/geo/provider/browser/bitbrowser.stub.ts // BitBrowser 占位。未来实现:调 BitBrowser Local API 启动 profile,playwright-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 { throw new Error('NotImplemented: BitBrowser 占位'); } async delete(_p: ProfileInfo): Promise { throw new Error('NotImplemented: BitBrowser 占位'); } async attachProxy(_p: ProfileInfo, _x: ProxyInfo): Promise { throw new Error('NotImplemented: BitBrowser 占位'); } async open(_p: ProfileInfo, _url?: string): Promise { throw new Error('NotImplemented: BitBrowser 占位'); } async close(_p: ProfileInfo): Promise { throw new Error('NotImplemented: BitBrowser 占位'); } } ``` ```typescript // 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 { throw new Error('NotImplemented: ant-browser 占位'); } async delete(_p: ProfileInfo): Promise { throw new Error('NotImplemented: ant-browser 占位'); } async attachProxy(_p: ProfileInfo, _x: ProxyInfo): Promise { throw new Error('NotImplemented: ant-browser 占位'); } async open(_p: ProfileInfo, _url?: string): Promise { throw new Error('NotImplemented: ant-browser 占位'); } async close(_p: ProfileInfo): Promise { throw new Error('NotImplemented: ant-browser 占位'); } } ``` ```typescript // 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 { throw new Error('NotImplemented: AdsPower 占位'); } async delete(_p: ProfileInfo): Promise { throw new Error('NotImplemented: AdsPower 占位'); } async attachProxy(_p: ProfileInfo, _x: ProxyInfo): Promise { throw new Error('NotImplemented: AdsPower 占位'); } async open(_p: ProfileInfo, _url?: string): Promise { throw new Error('NotImplemented: AdsPower 占位'); } async close(_p: ProfileInfo): Promise { throw new Error('NotImplemented: AdsPower 占位'); } } ``` - [ ] **Step 4:跑测试看通过** ```bash cd packages/backend && pnpm jest test/modules/geo/proxy_provider_tianqi.test.ts test/modules/geo/browser_provider_stub.test.ts ``` 预期:PASS(6 tests) - [ ] **Step 5:commit** ```bash 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 6:PlainChromiumProvider(S1 主实现) **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)** ```typescript // 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:跑测试看失败** ```bash cd packages/backend && pnpm jest test/modules/geo/browser_provider_plain_chromium.test.ts ``` 预期:FAIL - [ ] **Step 3:实现** ```typescript // 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 { 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 { 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 { 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 { 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 { try { this.exec(`playwright-cli -s=${profile.sessionName} close`); } catch {/* 容忍 */} } } ``` - [ ] **Step 4:跑测试看通过** ```bash cd packages/backend && pnpm jest test/modules/geo/browser_provider_plain_chromium.test.ts ``` 预期:PASS(7 tests) - [ ] **Step 5:commit** ```bash 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): PlainChromiumProvider(S1 主实现,playwright-cli + Chromium)" ``` --- ## Task 7:GeoProxyIp Entity **Files:** - Create: `packages/backend/src/modules/geo/entity/proxy_ip.ts` - [ ] **Step 1:实现** ```typescript // 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: '所属 Provider:local / 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:启动后端验证表自动创建** ```bash cd packages/backend && pnpm dev ``` 用 MCP 验证:`mcp__mysql__list_tables` 应包含 `geo_proxy_ip`。Ctrl+C 停止。 - [ ] **Step 3:commit** ```bash git add packages/backend/src/modules/geo/entity/proxy_ip.ts git commit -m "feat(geo): GeoProxyIp Entity" ``` --- ## Task 8:GeoBrowserProfile Entity **Files:** - Create: `packages/backend/src/modules/geo/entity/browser_profile.ts` - [ ] **Step 1:实现** ```typescript // 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: 'Provider:plain_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:启动后端验证** ```bash cd packages/backend && pnpm dev ``` MCP `list_tables` 验证 `geo_browser_profile`。Ctrl+C。 - [ ] **Step 3:commit** ```bash git add packages/backend/src/modules/geo/entity/browser_profile.ts git commit -m "feat(geo): GeoBrowserProfile Entity(sessionName/profileDir/configPath)" ``` --- ## Task 9:GeoAccount Entity **Files:** - Create: `packages/backend/src/modules/geo/entity/account.ts` - [ ] **Step 1:实现** ```typescript // 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: '人设 ID(S2 用)', nullable: true }) personaId: number; @Column({ comment: 'Agent 配置 ID(S3 用)', 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 3:commit** ```bash git add packages/backend/src/modules/geo/entity/account.ts git commit -m "feat(geo): GeoAccount Entity" ``` --- ## Task 10:GeoProxyIpService **Files:** - Create: `packages/backend/src/modules/geo/service/proxy_ip.ts` - Test: `packages/backend/test/modules/geo/service_proxy_ip.test.ts` - [ ] **Step 1:写测试** ```typescript // 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:跑测试看失败** ```bash cd packages/backend && pnpm jest test/modules/geo/service_proxy_ip.test.ts ``` - [ ] **Step 3:实现** ```typescript // 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; @Inject() encryptService: GeoEncryptService; @Logger() logger: ILogger; private readonly providers = new Map([ ['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 { return this.getProvider(dto.provider).acquire(dto); } async persist(info: ProxyInfo & { name?: string; provider?: string }, manager?: any): Promise { 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 { 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:跑测试看通过** ```bash cd packages/backend && pnpm jest test/modules/geo/service_proxy_ip.test.ts ``` - [ ] **Step 5:commit** ```bash 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 11:GeoBrowserProfileService **Files:** - Create: `packages/backend/src/modules/geo/service/browser_profile.ts` - Test: `packages/backend/test/modules/geo/service_browser_profile.test.ts` - [ ] **Step 1:写测试** ```typescript // 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:跑测试看失败** ```bash cd packages/backend && pnpm jest test/modules/geo/service_browser_profile.test.ts ``` - [ ] **Step 3:实现** ```typescript // 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; @Logger() logger: ILogger; private providers: Map = 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 { 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 { 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 { 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 { 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 { 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 5:commit** ```bash 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 12:GeoAccountService 编排核心 **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)** ```typescript // 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('add:profile 创建失败 → 触发 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('captureCookies:cookies 为空时不写库', 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('launch:profile.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:跑测试看失败** ```bash cd packages/backend && pnpm jest test/modules/geo/service_account.test.ts ``` - [ ] **Step 3:实现** ```typescript // 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; 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; @InjectEntityModel(GeoBrowserProfileEntity) profileEntity: Repository; @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 { 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 { 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 { 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:跑测试看通过** ```bash cd packages/backend && pnpm jest test/modules/geo/service_account.test.ts ``` - [ ] **Step 5:commit** ```bash 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 13:ProxyIp Controller **Files:** - Create: `packages/backend/src/modules/geo/controller/admin/proxy_ip.ts` - [ ] **Step 1:实现** ```typescript // 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 路由** ```bash 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 3:commit** ```bash git add packages/backend/src/modules/geo/controller/admin/proxy_ip.ts git commit -m "feat(geo): IP 池 Controller" ``` --- ## Task 14:BrowserProfile Controller **Files:** - Create: `packages/backend/src/modules/geo/controller/admin/browser_profile.ts` - [ ] **Step 1:实现** ```typescript // 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 3:commit** ```bash git add packages/backend/src/modules/geo/controller/admin/browser_profile.ts git commit -m "feat(geo): 指纹浏览器 Controller" ``` --- ## Task 15:Account Controller **Files:** - Create: `packages/backend/src/modules/geo/controller/admin/account.ts` - [ ] **Step 1:实现** ```typescript // 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 3:commit** ```bash 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 一级目录** ```sql INSERT INTO base_sys_menu (parentId, name, icon, orderNum, type, isShow, createTime, updateTime) VALUES (NULL, '🌍 GEO', 'icon-trend', 50, 0, 1, NOW(), NOW()); ``` 查 id: ```sql SELECT id FROM base_sys_menu WHERE name = '🌍 GEO' ORDER BY id DESC LIMIT 1; ``` 记为 ``。 - [ ] **Step 3:3 个二级菜单** ```sql INSERT INTO base_sys_menu (parentId, name, router, viewPath, orderNum, type, isShow, createTime, updateTime) VALUES (, '账号矩阵', '/geo/accounts', 'modules/geo/views/accounts.vue', 1, 1, 1, NOW(), NOW()), (, 'IP 池', '/geo/proxies', 'modules/geo/views/proxies.vue', 2, 1, 1, NOW(), NOW()), (, '指纹浏览器', '/geo/browser-profiles', 'modules/geo/views/browser-profiles.vue', 3, 1, 1, NOW(), NOW()); ``` 记下 3 个 id 为 `` `` ``。 - [ ] **Step 4:14 条按钮权限** ```sql INSERT INTO base_sys_menu (parentId, type, perms, createTime, updateTime) VALUES (, 2, 'geo:account:add', NOW(), NOW()), (, 2, 'geo:account:update', NOW(), NOW()), (, 2, 'geo:account:delete', NOW(), NOW()), (, 2, 'geo:account:launch', NOW(), NOW()), (, 2, 'geo:account:captureCookies', NOW(), NOW()), (, 2, 'geo:account:rebindIp', NOW(), NOW()), (, 2, 'geo:proxy:add', NOW(), NOW()), (, 2, 'geo:proxy:update', NOW(), NOW()), (, 2, 'geo:proxy:delete', NOW(), NOW()), (, 2, 'geo:proxy:healthCheck', NOW(), NOW()), (, 2, 'geo:profile:add', NOW(), NOW()), (, 2, 'geo:profile:update', NOW(), NOW()), (, 2, 'geo:profile:delete', NOW(), NOW()), (, 2, 'geo:profile:open', NOW(), NOW()); ``` - [ ] **Step 5:验证** ```sql 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 任务** ```sql 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:启动后端检查日志** ```bash 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 1:config.ts** ```typescript 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 2:dashboard.vue 占位** ```vue ``` - [ ] **Step 3:commit** ```bash 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 一致,无浏览器层耦合) ```vue ``` - [ ] **Step 2:启动前端验证** 打开 http://localhost:9001/geo/proxies。 - [ ] **Step 3:commit** ```bash 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:实现** ```vue ``` - [ ] **Step 2:启动前端验证** - [ ] **Step 3:commit** ```bash 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:实现** ```vue ``` - [ ] **Step 2:启动前端验证** - [ ] **Step 3:commit** ```bash git add packages/frontend/src/modules/geo/views/accounts.vue git commit -m "feat(geo)[fe]: 账号矩阵管理页" ``` --- ## Task 22:联调与冒烟(手工验证) - [ ] **Step 1:playwright-cli 自检** ```bash playwright-cli --version ``` 失败 → `npm i -g @playwright/cli@latest` - [ ] **Step 2:双端启动** ```bash cd packages/backend && pnpm dev # 终端 1 cd packages/frontend && pnpm dev # 终端 2 ``` - [ ] **Step 3:MCP mysql 验证三张表** ``` mcp__mysql__list_tables → 应包含 geo_account / geo_proxy_ip / geo_browser_profile ``` - [ ] **Step 4:MCP mysql 验证菜单** ```sql 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:9001,admin 登录。左侧应出现 `🌍 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。 数据库验证: ```sql 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 > 100,login_status='logged_in'。 - [ ] **Step 8:删除清理** 点击「删除」→ 确认。 ```sql SELECT id, status FROM geo_account WHERE id = ; SELECT id, status FROM geo_proxy_ip WHERE id = ; SELECT id, status FROM geo_browser_profile WHERE id = ; ``` 预期:account=deleted、proxy=unbound、profile=deleted。 - [ ] **Step 9:强绑定约束** ```sql UPDATE geo_proxy_ip SET bind_account_id = 9999 WHERE id = <已绑 IP id>; ``` 预期:唯一索引报错。 - [ ] **Step 10:playwright-cli 不可用降级** 临时改环境变量 `PATH` 让 playwright-cli 失踪 → 前端再点「打开」profile。 预期:弹窗"playwright-cli 未安装"。 - [ ] **Step 11:所有 geo 测试** ```bash cd packages/backend && pnpm jest test/modules/geo/ ``` 预期:全部 PASS。 - [ ] **Step 12:tsc 类型检查** ```bash 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 状态改为 ✅ 已完成。 ```bash 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 / close(Step 7) - [ ] base_sys_menu 已注入(Step 4) - [ ] 前端菜单可见(Step 5) - [ ] 完整闭环跑通(Step 6-7) - [ ] 删除安全释放(Step 8) - [ ] 强绑定约束生效(Step 9) - [ ] playwright-cli 不可用友好降级(Step 10) --- ## 实施期间约束 - ❌ 不写 SQL 文件(用 MCP mysql 直接 INSERT) - ❌ 不手动改 `entities.ts`(dev 模式通配符扫描,prod build 由 `cool entity` 生成) - ❌ 不引入 gateway/skill/runtime(S3 之后再考虑) - ❌ 不实现平台特定登录流程(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`](../specs/2026-05-03-geo-master-roadmap.md) §11 - 进入 S2(关键词与知识图谱)的 brainstorming 流程