# 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<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：跑测试看通过**

```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<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 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<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 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<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：跑测试看通过**

```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<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 等待对接文档');
  }
}
```

```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<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 占位'); }
}
```

```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<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 占位'); }
}
```

```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<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：跑测试看通过**

```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<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：跑测试看通过**

```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<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：跑测试看通过**

```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<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 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<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：跑测试看通过**

```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;
```
记为 `<GEO_ROOT_ID>`。

- [ ] **Step 3：3 个二级菜单**

```sql
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 4：14 条按钮权限**

```sql
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：验证**

```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
<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 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
<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 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
<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 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
<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 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 = <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：强绑定约束**

```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 流程
