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

2680 lines
92 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 + ChromiumBitBrowser/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-cliNeta 已有 skill全局 CLI/ jest。
---
## 文件结构
### 后端文件(新建 17 个)
| 路径 | 职责 |
|---|---|
| `packages/backend/src/modules/geo/config.ts` | 模块配置 |
| `packages/backend/src/modules/geo/entity/account.ts` | `geo_account` Entity |
| `packages/backend/src/modules/geo/entity/proxy_ip.ts` | `geo_proxy_ip` Entity |
| `packages/backend/src/modules/geo/entity/browser_profile.ts` | `geo_browser_profile` Entity |
| `packages/backend/src/modules/geo/service/encrypt.ts` | GeoEncryptService AES-256-GCM |
| `packages/backend/src/modules/geo/service/browser_automation.ts` | **统一自动化层**playwright-cli 包装) |
| `packages/backend/src/modules/geo/service/proxy_ip.ts` | GeoProxyIpService |
| `packages/backend/src/modules/geo/service/browser_profile.ts` | GeoBrowserProfileService |
| `packages/backend/src/modules/geo/service/account.ts` | GeoAccountService 编排核心 |
| `packages/backend/src/modules/geo/controller/admin/proxy_ip.ts` | IP 池 Controller |
| `packages/backend/src/modules/geo/controller/admin/browser_profile.ts` | 指纹浏览器 Controller |
| `packages/backend/src/modules/geo/controller/admin/account.ts` | 账号 Controller |
| `packages/backend/src/modules/geo/provider/proxy/interface.ts` | IProxyProvider |
| `packages/backend/src/modules/geo/provider/proxy/local.ts` | LocalProxyProvider |
| `packages/backend/src/modules/geo/provider/proxy/tianqi.ts` | 天启占位 |
| `packages/backend/src/modules/geo/provider/browser/interface.ts` | IBrowserProvider |
| `packages/backend/src/modules/geo/provider/browser/plain_chromium.ts` | **★ S1 主实现**playwright-cli + Chromium |
| `packages/backend/src/modules/geo/provider/browser/bitbrowser.stub.ts` | BitBrowser 占位 |
| `packages/backend/src/modules/geo/provider/browser/ant_browser.stub.ts` | ant-browser 占位 |
| `packages/backend/src/modules/geo/provider/browser/adspower.stub.ts` | AdsPower 占位 |
### 测试文件(新建 8 个)
| 路径 | 测试对象 |
|---|---|
| `packages/backend/test/modules/geo/encrypt.test.ts` | GeoEncryptService |
| `packages/backend/test/modules/geo/browser_automation.test.ts` | BrowserAutomationServicemock execSync |
| `packages/backend/test/modules/geo/proxy_provider_local.test.ts` | LocalProxyProvider |
| `packages/backend/test/modules/geo/proxy_provider_tianqi.test.ts` | TianqiProxyProvider 占位 |
| `packages/backend/test/modules/geo/browser_provider_plain_chromium.test.ts` | PlainChromiumProvidermock execSync |
| `packages/backend/test/modules/geo/browser_provider_stub.test.ts` | BitBrowser/AntBrowser/AdsPower 占位 |
| `packages/backend/test/modules/geo/service_proxy_ip.test.ts` | GeoProxyIpService |
| `packages/backend/test/modules/geo/service_browser_profile.test.ts` | GeoBrowserProfileService |
| `packages/backend/test/modules/geo/service_account.test.ts` | GeoAccountService |
### 前端文件(新建 5 个)
| 路径 | 职责 |
|---|---|
| `packages/frontend/src/modules/geo/config.ts` | 前端模块配置 |
| `packages/frontend/src/modules/geo/views/dashboard.vue` | 占位 |
| `packages/frontend/src/modules/geo/views/proxies.vue` | IP 池 |
| `packages/frontend/src/modules/geo/views/browser-profiles.vue` | 指纹浏览器 |
| `packages/frontend/src/modules/geo/views/accounts.vue` | 账号矩阵 |
### 数据库种子(通过 MCP mysql 工具执行 INSERT
- `base_sys_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 0geo 模块脚手架
**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 4commit**
```bash
git add packages/backend/src/modules/geo/config.ts
git commit -m "feat(geo): 新建 geo 模块脚手架S1"
```
---
## Task 1GeoEncryptService
**Files:**
- Create: `packages/backend/src/modules/geo/service/encrypt.ts`
- Test: `packages/backend/test/modules/geo/encrypt.test.ts`
- [ ] **Step 1写测试**
```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
```
预期PASS4 tests
- [ ] **Step 5commit**
```bash
git add packages/backend/src/modules/geo/service/encrypt.ts packages/backend/test/modules/geo/encrypt.test.ts
git commit -m "feat(geo): 加密服务 GeoEncryptServiceAES-256-GCM"
```
---
## Task 2BrowserAutomationService统一自动化层
> **关键设计**:这是 S1 与未来 S3 共用的自动化基础。所有 cookie 抓取、状态保存都通过它,不再分散到各 Provider 里。
**Files:**
- Create: `packages/backend/src/modules/geo/service/browser_automation.ts`
- Test: `packages/backend/test/modules/geo/browser_automation.test.ts`
- [ ] **Step 1写测试**
```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 });
}
/** 列出 cookiedomain 可选 */
async getCookies(sessionName: string, domain?: string): Promise<CookieItem[]> {
const domainArg = domain ? ` --domain=${domain}` : '';
const cmd = `playwright-cli -s=${sessionName} --raw cookie-list${domainArg}`;
let out: string;
try {
out = this.exec(cmd);
} catch (e: any) {
throw new Error(`BrowserAutomationFailed: ${cmd}${e.message}`);
}
try {
return JSON.parse(out.trim());
} catch {
this.logger.warn(`getCookies output not JSON: ${out.slice(0, 200)}`);
return [];
}
}
/** 保存完整登录态cookie + localStorage到文件 */
async saveState(sessionName: string, filePath: string): Promise<void> {
const cmd = `playwright-cli -s=${sessionName} state-save ${filePath}`;
try {
this.exec(cmd);
} catch (e: any) {
throw new Error(`BrowserAutomationFailed: ${cmd}${e.message}`);
}
}
/** 从文件恢复登录态 */
async loadState(sessionName: string, filePath: string): Promise<void> {
const cmd = `playwright-cli -s=${sessionName} state-load ${filePath}`;
try {
this.exec(cmd);
} catch (e: any) {
throw new Error(`BrowserAutomationFailed: ${cmd}${e.message}`);
}
}
/** S3 后续会增加 click / type / snapshot 等方法 */
}
```
- [ ] **Step 4跑测试看通过**
```bash
cd packages/backend && pnpm jest test/modules/geo/browser_automation.test.ts
```
预期PASS6 tests
- [ ] **Step 5commit**
```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 3Provider 接口定义
**Files:**
- Create: `packages/backend/src/modules/geo/provider/proxy/interface.ts`
- Create: `packages/backend/src/modules/geo/provider/browser/interface.ts`
- [ ] **Step 1IProxyProvider**
```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 2IBrowserProvider仅进程生命周期不含自动化**
```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 3tsc 类型检查**
```bash
cd packages/backend && npx tsc --noEmit
```
预期:通过
- [ ] **Step 4commit**
```bash
git add packages/backend/src/modules/geo/provider/
git commit -m "feat(geo): IProxyProvider 与 IBrowserProvider 接口(自动化已剥离到 BrowserAutomationService"
```
---
## Task 4LocalProxyProvider
**Files:**
- Create: `packages/backend/src/modules/geo/provider/proxy/local.ts`
- Test: `packages/backend/test/modules/geo/proxy_provider_local.test.ts`
- [ ] **Step 1写测试**
```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
```
预期PASS4 tests
- [ ] **Step 5commit**
```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占位 ProviderTianqi + 浏览器三个 stub
**Files:**
- Create: `packages/backend/src/modules/geo/provider/proxy/tianqi.ts`
- Create: `packages/backend/src/modules/geo/provider/browser/bitbrowser.stub.ts`
- Create: `packages/backend/src/modules/geo/provider/browser/ant_browser.stub.ts`
- Create: `packages/backend/src/modules/geo/provider/browser/adspower.stub.ts`
- Test: `packages/backend/test/modules/geo/proxy_provider_tianqi.test.ts`
- Test: `packages/backend/test/modules/geo/browser_provider_stub.test.ts`
- [ ] **Step 1写测试**
```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 启动 profileplaywright-cli attach 进去
import { IBrowserProvider, CreateOpts, ProfileInfo } from './interface';
import type { ProxyInfo } from '../proxy/interface';
export class BitBrowserStubProvider implements IBrowserProvider {
readonly name = 'bitbrowser';
async create(_o: CreateOpts): Promise<ProfileInfo> { throw new Error('NotImplemented: BitBrowser 占位'); }
async delete(_p: ProfileInfo): Promise<void> { throw new Error('NotImplemented: BitBrowser 占位'); }
async attachProxy(_p: ProfileInfo, _x: ProxyInfo): Promise<void> { throw new Error('NotImplemented: BitBrowser 占位'); }
async open(_p: ProfileInfo, _url?: string): Promise<void> { throw new Error('NotImplemented: BitBrowser 占位'); }
async close(_p: ProfileInfo): Promise<void> { throw new Error('NotImplemented: BitBrowser 占位'); }
}
```
```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
```
预期PASS6 tests
- [ ] **Step 5commit**
```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 6PlainChromiumProviderS1 主实现)
**Files:**
- Create: `packages/backend/src/modules/geo/provider/browser/plain_chromium.ts`
- Test: `packages/backend/test/modules/geo/browser_provider_plain_chromium.test.ts`
- [ ] **Step 1写测试mock execSync + fs**
```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
```
预期PASS7 tests
- [ ] **Step 5commit**
```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): PlainChromiumProviderS1 主实现playwright-cli + Chromium"
```
---
## Task 7GeoProxyIp 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: '所属 Providerlocal / tianqi', length: 32 })
provider: string;
@Column({ comment: '模式local / third_party', length: 16 })
mode: string;
@Column({ comment: '代理主机', nullable: true, length: 128 })
host: string;
@Column({ comment: '代理端口', nullable: true })
port: number;
@Column({ comment: '协议 http/socks5', length: 8, default: 'http' })
protocol: string;
@Column({ comment: '用户名(加密)', nullable: true, length: 256 })
username: string;
@Column({ comment: '密码(加密)', nullable: true, length: 512 })
password: string;
@Column({ comment: '区域', nullable: true, length: 64 })
region: string;
@Column({ comment: 'ISP', nullable: true, length: 32 })
isp: string;
@Column({ comment: 'Provider 侧外部 ID', nullable: true, length: 128 })
externalId: string;
@Index({ unique: true, where: 'bind_account_id IS NOT NULL' })
@Column({ comment: '绑定账号 ID强 1:1', nullable: true })
bindAccountId: number;
@Column({ comment: '状态 active/expired/error/unbound', length: 16, default: 'unbound' })
status: string;
@Column({ comment: '健康检查延迟 ms', nullable: true })
latencyMs: number;
@Column({ comment: '上次检查时间', type: 'datetime', nullable: true })
lastCheckAt: Date;
@Column({ comment: '过期时间', type: 'datetime', nullable: true })
expiresAt: Date;
@Column({ comment: '扩展字段', type: 'json', nullable: true })
extra: any;
}
```
- [ ] **Step 2启动后端验证表自动创建**
```bash
cd packages/backend && pnpm dev
```
用 MCP 验证:`mcp__mysql__list_tables` 应包含 `geo_proxy_ip`。Ctrl+C 停止。
- [ ] **Step 3commit**
```bash
git add packages/backend/src/modules/geo/entity/proxy_ip.ts
git commit -m "feat(geo): GeoProxyIp Entity"
```
---
## Task 8GeoBrowserProfile Entity
**Files:**
- Create: `packages/backend/src/modules/geo/entity/browser_profile.ts`
- [ ] **Step 1实现**
```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: 'Providerplain_chromium / bitbrowser / ant_browser / adspower', length: 32 })
provider: string;
@Index()
@Column({ comment: 'playwright-cli 命名 session唯一标识', length: 128 })
sessionName: string;
@Index({ unique: true, where: 'account_id IS NOT NULL' })
@Column({ comment: '绑定账号 ID强 1:1', nullable: true })
accountId: number;
@Column({ comment: 'profile 持久化目录', nullable: true, length: 512 })
profileDir: string;
@Column({ comment: '代理配置文件路径', nullable: true, length: 512 })
configPath: string;
@Column({ comment: 'UserAgent', nullable: true, length: 512 })
userAgent: string;
@Column({ comment: 'OS 平台', nullable: true, length: 32 })
osPlatform: string;
@Column({ comment: '时区', nullable: true, length: 32 })
timezone: string;
@Column({ comment: '语言', nullable: true, length: 16 })
language: string;
@Column({ comment: '屏幕宽', nullable: true })
screenW: number;
@Column({ comment: '屏幕高', nullable: true })
screenH: number;
@Column({ comment: '深度指纹S1 不填,等 ant-browser', type: 'json', nullable: true })
fingerprint: any;
@Column({ comment: '上次打开时间', type: 'datetime', nullable: true })
lastOpenAt: Date;
@Column({ comment: '状态 created/running/closed/error/deleted', length: 16, default: 'created' })
status: string;
@Column({ comment: '扩展字段', type: 'json', nullable: true })
extra: any;
}
```
- [ ] **Step 2启动后端验证**
```bash
cd packages/backend && pnpm dev
```
MCP `list_tables` 验证 `geo_browser_profile`。Ctrl+C。
- [ ] **Step 3commit**
```bash
git add packages/backend/src/modules/geo/entity/browser_profile.ts
git commit -m "feat(geo): GeoBrowserProfile EntitysessionName/profileDir/configPath"
```
---
## Task 9GeoAccount Entity
**Files:**
- Create: `packages/backend/src/modules/geo/entity/account.ts`
- [ ] **Step 1实现**
```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: '人设 IDS2 用)', nullable: true })
personaId: number;
@Column({ comment: 'Agent 配置 IDS3 用)', nullable: true })
agentConfigId: number;
@Column({ comment: '上次活跃时间', type: 'datetime', nullable: true })
lastActiveAt: Date;
@Column({ comment: '平台特定字段', type: 'json', nullable: true })
extra: any;
}
```
- [ ] **Step 2启动验证三张表全部就位**
`mcp__mysql__list_tables` → geo_account / geo_proxy_ip / geo_browser_profile 都在。
- [ ] **Step 3commit**
```bash
git add packages/backend/src/modules/geo/entity/account.ts
git commit -m "feat(geo): GeoAccount Entity"
```
---
## Task 10GeoProxyIpService
**Files:**
- Create: `packages/backend/src/modules/geo/service/proxy_ip.ts`
- Test: `packages/backend/test/modules/geo/service_proxy_ip.test.ts`
- [ ] **Step 1写测试**
```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 5commit**
```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 11GeoBrowserProfileService
**Files:**
- Create: `packages/backend/src/modules/geo/service/browser_profile.ts`
- Test: `packages/backend/test/modules/geo/service_browser_profile.test.ts`
- [ ] **Step 1写测试**
```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 5commit**
```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 12GeoAccountService 编排核心
**Files:**
- Create: `packages/backend/src/modules/geo/service/account.ts`
- Test: `packages/backend/test/modules/geo/service_account.test.ts`
- [ ] **Step 1写测试覆盖add 编排、补偿、launch、captureCookies、rebindIp**
```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('addprofile 创建失败 → 触发 IP 补偿', async () => {
profileSvc.createAndPersist.mockRejectedValueOnce(new Error('boom'));
await expect(svc.add({ name: 'a', platform: 'x', loginAccount: '1', ipMode: 'local' } as any)).rejects.toThrow('boom');
expect(proxySvc.getProvider).toHaveBeenCalledWith('local');
});
it('captureCookiescookies 为空时不写库', async () => {
accountRepo.findOneBy.mockResolvedValue({ id: 1, browserProfileId: 22 });
(svc as any).profileEntity.findOneBy.mockResolvedValue({ id: 22, sessionName: 'geo-22', status: 'running' });
automationSvc.getCookies.mockResolvedValue([]);
const r = await svc.captureCookies(1);
expect(r.captured).toBe(0);
expect(accountRepo.update).not.toHaveBeenCalled();
});
it('captureCookies抓到 cookie 后加密存储 + saveState', async () => {
accountRepo.findOneBy.mockResolvedValue({ id: 1, browserProfileId: 22 });
(svc as any).profileEntity.findOneBy.mockResolvedValue({ id: 22, sessionName: 'geo-22', status: 'running' });
automationSvc.getCookies.mockResolvedValue([{ name: 'sid', value: 'abc', domain: '.xhs.com' }]);
await svc.captureCookies(1, ['xhs.com']);
expect(automationSvc.getCookies).toHaveBeenCalledWith('geo-22', 'xhs.com');
expect(accountRepo.update).toHaveBeenCalledWith(1, expect.objectContaining({
loginStatus: 'logged_in',
cookies: expect.stringMatching(/^enc:/),
}));
expect(automationSvc.saveState).toHaveBeenCalled();
});
it('launchprofile.status 设 running', async () => {
accountRepo.findOneBy.mockResolvedValue({ id: 1, browserProfileId: 22 });
await svc.launch(1, 'https://example.com');
expect(profileSvc.open).toHaveBeenCalledWith(22, 'https://example.com');
});
});
```
- [ ] **Step 2跑测试看失败**
```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 5commit**
```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 13ProxyIp 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 3commit**
```bash
git add packages/backend/src/modules/geo/controller/admin/proxy_ip.ts
git commit -m "feat(geo): IP 池 Controller"
```
---
## Task 14BrowserProfile Controller
**Files:**
- Create: `packages/backend/src/modules/geo/controller/admin/browser_profile.ts`
- [ ] **Step 1实现**
```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 3commit**
```bash
git add packages/backend/src/modules/geo/controller/admin/browser_profile.ts
git commit -m "feat(geo): 指纹浏览器 Controller"
```
---
## Task 15Account Controller
**Files:**
- Create: `packages/backend/src/modules/geo/controller/admin/account.ts`
- [ ] **Step 1实现**
```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 3commit**
```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 33 个二级菜单**
```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 414 条按钮权限**
```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 1config.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 2dashboard.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 3commit**
```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 3commit**
```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 3commit**
```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 3commit**
```bash
git add packages/frontend/src/modules/geo/views/accounts.vue
git commit -m "feat(geo)[fe]: 账号矩阵管理页"
```
---
## Task 22联调与冒烟手工验证
- [ ] **Step 1playwright-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 3MCP mysql 验证三张表**
```
mcp__mysql__list_tables → 应包含 geo_account / geo_proxy_ip / geo_browser_profile
```
- [ ] **Step 4MCP 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:9001admin 登录。左侧应出现 `🌍 GEO`
- [ ] **Step 6本地 IP 流程**
进入 IP 池 → 新增 → name=test-local / provider=local / mode=local → 创建 → 健康检查 → 应返回延迟。
- [ ] **Step 7账号完整闭环关键**
进入 账号矩阵 → 新增账号 → name=xhs-test / 平台=xiaohongshu / loginAccount=13800001234 / IP 模式=local / 浏览器 Provider=plain_chromium → 创建。
预期:
- 列表新行 proxyId / browserProfileId 都有值
- 数据库 geo_proxy_ip 对应记录 bindAccountId 写了
- geo_browser_profile 对应记录 accountId 写了sessionName=`geo-{id}`
点击「启动登录」→ Chromium 弹真实窗口(已打开小红书首页)→ 在窗口里扫码登录。
回控制台点击「抓 Cookie」→ 提示"已抓到 N 条 cookie" → 列表刷新loginStatus 变 logged_in。
数据库验证:
```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 > 100login_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 10playwright-cli 不可用降级**
临时改环境变量 `PATH` 让 playwright-cli 失踪 → 前端再点「打开」profile。
预期:弹窗"playwright-cli 未安装"。
- [ ] **Step 11所有 geo 测试**
```bash
cd packages/backend && pnpm jest test/modules/geo/
```
预期:全部 PASS。
- [ ] **Step 12tsc 类型检查**
```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 / closeStep 7
- [ ] base_sys_menu 已注入Step 4
- [ ] 前端菜单可见Step 5
- [ ] 完整闭环跑通Step 6-7
- [ ] 删除安全释放Step 8
- [ ] 强绑定约束生效Step 9
- [ ] playwright-cli 不可用友好降级Step 10
---
## 实施期间约束
- ❌ 不写 SQL 文件(用 MCP mysql 直接 INSERT
- ❌ 不手动改 `entities.ts`dev 模式通配符扫描prod build 由 `cool entity` 生成)
- ❌ 不引入 gateway/skill/runtimeS3 之后再考虑)
- ❌ 不实现平台特定登录流程QR/账密/短信全部 S3
- ❌ 不在 controller 写业务逻辑
- ❌ 不绕过 Cool Admin 自动 CRUD 写手工 axios
- ❌ 不在 BrowserProvider 里做 cookie/state 操作(那是 BrowserAutomationService 的职责)
---
## 实施完成后
- 调用 `superpowers:verification-before-completion` 逐条核对验收清单
- 调用 `superpowers:requesting-code-review` 触发代码审查
- 调用 `superpowers:finishing-a-development-branch` 决策合并方式
- 更新 [`../specs/2026-05-03-geo-master-roadmap.md`](../specs/2026-05-03-geo-master-roadmap.md) §11
- 进入 S2关键词与知识图谱的 brainstorming 流程