GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-05-03-geo-s1-infrastructure-plan.md

2680 lines
92 KiB
Markdown
Raw Normal View History

2026-05-20 21:39:12 +08:00
# 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 流程