592 lines
27 KiB
Markdown
592 lines
27 KiB
Markdown
|
|
# S1 基础设施层 设计文档
|
|||
|
|
|
|||
|
|
> Geo 模块第一个子项目:账号矩阵 / IP 池 / 指纹浏览器 / 菜单。
|
|||
|
|
> 上位文档:[`2026-05-03-geo-master-roadmap.md`](./2026-05-03-geo-master-roadmap.md)
|
|||
|
|
|
|||
|
|
| 元数据 | 值 |
|
|||
|
|
|---|---|
|
|||
|
|
| 子项目 | S1 基础设施层 |
|
|||
|
|
| 创建日期 | 2026-05-03 |
|
|||
|
|
| 估期 | ~9 天 |
|
|||
|
|
| 状态 | 设计完成,待 user 复核 → writing-plans |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. 目标
|
|||
|
|
|
|||
|
|
为 Geo 模块后续所有子项目(S2/S3/S4)提供**账号—IP—指纹浏览器**三位一体的基础设施。S1 完成后,用户能在 Neta 控制台:
|
|||
|
|
|
|||
|
|
1. 在 GEO 一级菜单下看到三个子页面
|
|||
|
|
2. 创建一个 IP(本地或第三方)
|
|||
|
|
3. 创建一个指纹浏览器 profile
|
|||
|
|
4. 创建一个社媒账号,自动绑定 IP + Profile(强 1:1:1)
|
|||
|
|
5. 点击「启动登录」打开真实浏览器,扫码/输账密/输短信完成登录
|
|||
|
|
6. Cookie 自动抱回并加密存储
|
|||
|
|
7. 删除账号时安全释放 IP 和 Profile
|
|||
|
|
|
|||
|
|
S1 **不涉及**任何平台特定行为(发布、评论、监测)。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. 关键决策(brainstorming 阶段已确认)
|
|||
|
|
|
|||
|
|
| 决策点 | 决策 |
|
|||
|
|
|---|---|
|
|||
|
|
| 子项目拆分 | 4 个子项目(S1/S2/S3/S4),先做 S1 |
|
|||
|
|
| IP 池 | 抽象 `IProxyProvider` + 内置 `LocalProvider` + 占位 `TianqiProvider` |
|
|||
|
|
| 浏览器(两层正交) | ① 浏览器进程层 `IBrowserProvider`:S1 主实现 `PlainChromiumProvider`(playwright-cli 直接启 Chromium)+ 占位 `BitBrowser` `AntBrowser` `AdsPower`。② 自动化层 `BrowserAutomationService`:统一用 `playwright-cli -s={session}` 做 cookie/state/click/type,不关心底层是哪种浏览器 |
|
|||
|
|
| 绑定关系 | account ↔ proxy_ip ↔ browser_profile **严格 1:1:1**,IP/Profile 上 `bindAccountId` 唯一索引 |
|
|||
|
|
| 登录方式 | `playwright-cli -s={session} open {url} --persistent --headed` 启动有头浏览器 + 用户自己登录 + `BrowserAutomationService.getCookies()` 自动抱回 |
|
|||
|
|
| 实现风格 | 分层 Provider 抽象 + account service 单点编排(与 NetaClaw `llm_providers/` 对齐) |
|
|||
|
|
| 数据库 | 不写 SQL 文件;Entity + TypeORM `synchronize` 自动建表;菜单 seed 用 `mcp__mysql__execute` |
|
|||
|
|
| 加密 | geo 模块自建 `GeoEncryptService`(复用同一 AES-256-GCM 算法和 `SKILL_SECRET_KEY`/`APP_SECRET` 密钥派生逻辑,但接口签名为 `encrypt(plain: string): string` / `decrypt(cipher: string): string`,适配 cookie/密码等字符串场景。不直接调用 `SkillSecretService`——后者签名为 `encrypt(obj: Record<string,string>)` 且语义是 skill scoped secrets) |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. 与 Neta 现有架构契合度审计
|
|||
|
|
|
|||
|
|
| Neta 约定 | S1 实施 |
|
|||
|
|
|---|---|
|
|||
|
|
| 模块根目录 `src/modules/{name}/` | ✅ `src/modules/geo/` |
|
|||
|
|
| `entity/` 文件下划线 + BaseEntity + 字段驼峰 | ✅ |
|
|||
|
|
| `controller/admin/` + `@CoolController` 自动 CRUD | ✅ |
|
|||
|
|
| `service/` 业务编排 | ✅ |
|
|||
|
|
| `entities.ts` 注册 | ✅ 自动生成(`cool entity` 命令扫描 `modules/*/entity` 通配符,无需手动修改) |
|
|||
|
|
| 前端模块 `config.ts` 导出 ModuleConfig | ✅ |
|
|||
|
|
| 加密复用 AES-256-GCM 算法和密钥派生 | ✅ 自建 `GeoEncryptService`(复用算法+密钥,接口适配字符串场景) |
|
|||
|
|
| 菜单走 base_sys_menu | ✅ |
|
|||
|
|
| TypeORM `synchronize: true` 自动建表 | ✅(dev 环境,prod 禁用) |
|
|||
|
|
| 多租户 tenantId 自动注入 | ✅ |
|
|||
|
|
| 不在 S1:gateway/skill/runtime(属过度设计) | ✅ |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. 架构与目录
|
|||
|
|
|
|||
|
|
### 4.1 后端目录
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
packages/backend/src/
|
|||
|
|
└── modules/geo/ # entities.ts 由 `cool entity` 自动生成,无需手动修改
|
|||
|
|
├── config.ts
|
|||
|
|
├── entity/
|
|||
|
|
│ ├── account.ts # geo_account
|
|||
|
|
│ ├── proxy_ip.ts # geo_proxy_ip
|
|||
|
|
│ └── browser_profile.ts # geo_browser_profile
|
|||
|
|
├── controller/admin/
|
|||
|
|
│ ├── account.ts # @CoolController + 自定义 launch / captureCookies
|
|||
|
|
│ ├── proxy_ip.ts # @CoolController + 自定义 healthCheck / batchImport
|
|||
|
|
│ └── browser_profile.ts # @CoolController + 自定义 open / close
|
|||
|
|
├── service/
|
|||
|
|
│ ├── account.ts # ★ 单点编排:bindResources / launch / captureCookies / rebindIp
|
|||
|
|
│ ├── proxy_ip.ts # CRUD 包装 + provider 调度
|
|||
|
|
│ ├── browser_profile.ts # CRUD 包装 + provider 调度
|
|||
|
|
│ ├── browser_automation.ts # ★ 统一自动化层:playwright-cli 包装(cookie/state/click/type)
|
|||
|
|
│ └── encrypt.ts # GeoEncryptService
|
|||
|
|
└── provider/ # geo 模块内部约定(非 NetaClaw plugins/)
|
|||
|
|
# 原因:geo provider 不需要动态加载,且 plugins/ 在 Neta 语义里
|
|||
|
|
# 特指 LLM 适配层(netaclaw/plugins/llm_providers/)
|
|||
|
|
├── proxy/
|
|||
|
|
│ ├── interface.ts # IProxyProvider
|
|||
|
|
│ ├── local.ts # LocalProxyProvider
|
|||
|
|
│ └── tianqi.ts # 占位 TianqiProxyProvider(等文档)
|
|||
|
|
└── browser/
|
|||
|
|
├── interface.ts # IBrowserProvider(仅进程生命周期)
|
|||
|
|
├── plain_chromium.ts # ★ S1 主实现:playwright-cli + Chromium
|
|||
|
|
├── bitbrowser.stub.ts # 占位(启动 BitBrowser,playwright-cli attach)
|
|||
|
|
├── ant_browser.stub.ts # 占位(等用户提供源码)
|
|||
|
|
└── adspower.stub.ts # 占位
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.2 前端目录
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
packages/frontend/src/modules/geo/
|
|||
|
|
├── config.ts # ★ 必须 export ModuleConfig
|
|||
|
|
└── views/ # service 代理由 Cool Admin 根据后端 controller 文件名自动生成
|
|||
|
|
# 如 service.geo.account.page() / service.geo.proxy_ip.healthCheck()
|
|||
|
|
# 无需手动创建 service/ 目录
|
|||
|
|
├── dashboard.vue # 占位(S4 才正式做内容)
|
|||
|
|
├── accounts.vue # 账号矩阵主页
|
|||
|
|
├── proxies.vue # IP 池
|
|||
|
|
└── browser-profiles.vue # 指纹浏览器
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.3 菜单 seed(运行时通过 MCP mysql 执行 INSERT)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
🌍 GEO(一级目录,icon=icon-trend,order=50)
|
|||
|
|
├── 账号矩阵 viewPath=modules/geo/views/accounts.vue perms=geo:account:list
|
|||
|
|
├── IP 池 viewPath=modules/geo/views/proxies.vue perms=geo:proxy:list
|
|||
|
|
└── 指纹浏览器 viewPath=modules/geo/views/browser-profiles.vue perms=geo:profile:list
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
按钮权限:`geo:account:add` / `update` / `delete` / `launch` / `captureCookies` / `rebindIp`;`geo:proxy:add` / `update` / `delete` / `healthCheck` / `batchImport`;`geo:profile:add` / `update` / `delete` / `open` / `close`。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. 数据模型
|
|||
|
|
|
|||
|
|
### 5.1 geo_account
|
|||
|
|
|
|||
|
|
| 字段 | 类型 | 说明 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `id` | bigint PK | BaseEntity |
|
|||
|
|
| `tenantId` | bigint Index | BaseEntity 自动注入 |
|
|||
|
|
| `name` | varchar(64) | 备注名/昵称 |
|
|||
|
|
| `platform` | varchar(32) Index | xiaohongshu / douyin / weibo / zhihu / wechat |
|
|||
|
|
| `loginAccount` | varchar(128) | 登录手机号/账号 |
|
|||
|
|
| `cookies` | text | **加密** JSON |
|
|||
|
|
| `cookieCapturedAt` | datetime NULL | |
|
|||
|
|
| `loginStatus` | varchar(16) | never / logged_in / expired |
|
|||
|
|
| `status` | varchar(16) | draft / active / risky / banned / deleted |
|
|||
|
|
| `proxyId` | bigint UQ Index | → geo_proxy_ip.id |
|
|||
|
|
| `browserProfileId` | bigint UQ Index | → geo_browser_profile.id |
|
|||
|
|
| `personaId` | bigint NULL | S2 用,S1 占位 |
|
|||
|
|
| `agentConfigId` | bigint NULL | S3 用 |
|
|||
|
|
| `lastActiveAt` | datetime NULL | |
|
|||
|
|
| `extra` | json | 平台特定字段 |
|
|||
|
|
| `createTime` `updateTime` | datetime | BaseEntity |
|
|||
|
|
|
|||
|
|
**约束**:(`platform`, `loginAccount`) 联合唯一;`proxyId` 唯一;`browserProfileId` 唯一。
|
|||
|
|
|
|||
|
|
### 5.2 geo_proxy_ip
|
|||
|
|
|
|||
|
|
| 字段 | 类型 | 说明 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `id` | bigint PK | |
|
|||
|
|
| `tenantId` | bigint Index | |
|
|||
|
|
| `name` | varchar(64) | |
|
|||
|
|
| `provider` | varchar(32) | local / tianqi |
|
|||
|
|
| `mode` | varchar(16) | local / third_party |
|
|||
|
|
| `host` | varchar(128) NULL | 第三方才有 |
|
|||
|
|
| `port` | int NULL | |
|
|||
|
|
| `protocol` | varchar(8) | http / socks5 |
|
|||
|
|
| `username` | varchar(128) NULL | **加密** |
|
|||
|
|
| `password` | varchar(255) NULL | **加密** |
|
|||
|
|
| `region` | varchar(64) | 城市/省份 |
|
|||
|
|
| `isp` | varchar(32) | 联通/电信/移动 |
|
|||
|
|
| `externalId` | varchar(128) NULL | provider 侧 ID |
|
|||
|
|
| `bindAccountId` | bigint UQ NULL Index | 反向唯一 |
|
|||
|
|
| `status` | varchar(16) | active / expired / error / unbound |
|
|||
|
|
| `latencyMs` | int NULL | |
|
|||
|
|
| `lastCheckAt` | datetime NULL | |
|
|||
|
|
| `expiresAt` | datetime NULL | |
|
|||
|
|
| `extra` | json | |
|
|||
|
|
|
|||
|
|
### 5.3 geo_browser_profile
|
|||
|
|
|
|||
|
|
| 字段 | 类型 | 说明 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| `id` | bigint PK | |
|
|||
|
|
| `tenantId` | bigint Index | |
|
|||
|
|
| `name` | varchar(64) | |
|
|||
|
|
| `provider` | varchar(32) | plain_chromium / bitbrowser / ant_browser / adspower |
|
|||
|
|
| `sessionName` | varchar(128) Index | playwright-cli 的 `-s=xxx` 命名 session(唯一标识) |
|
|||
|
|
| `accountId` | bigint UQ NULL Index | 反向唯一 |
|
|||
|
|
| `profileDir` | varchar(512) NULL | `--profile=/path` 持久化目录 |
|
|||
|
|
| `configPath` | varchar(512) NULL | `--config=xxx.json` 代理配置文件路径 |
|
|||
|
|
| `userAgent` | varchar(512) | |
|
|||
|
|
| `osPlatform` | varchar(32) | windows / mac / linux |
|
|||
|
|
| `timezone` | varchar(32) | |
|
|||
|
|
| `language` | varchar(16) | |
|
|||
|
|
| `screenW` | int | |
|
|||
|
|
| `screenH` | int | |
|
|||
|
|
| `fingerprint` | json | 深度指纹配置(S1 不填,等 ant-browser) |
|
|||
|
|
| `lastOpenAt` | datetime NULL | |
|
|||
|
|
| `status` | varchar(16) | created / running / closed / error / deleted |
|
|||
|
|
| `extra` | json | |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 6. Provider 接口契约
|
|||
|
|
|
|||
|
|
### 6.1 IProxyProvider
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// 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';
|
|||
|
|
host?: string;
|
|||
|
|
port?: number;
|
|||
|
|
protocol: 'http' | 'socks5';
|
|||
|
|
username?: string;
|
|||
|
|
password?: string;
|
|||
|
|
region?: string;
|
|||
|
|
isp?: string;
|
|||
|
|
expiresAt?: Date;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface IProxyProvider {
|
|||
|
|
readonly name: string;
|
|||
|
|
acquire(opts: AcquireOpts): Promise<ProxyInfo>;
|
|||
|
|
release(externalId: string): Promise<void>;
|
|||
|
|
healthCheck(p: ProxyInfo): Promise<{ ok: boolean; latencyMs: number }>;
|
|||
|
|
list?(): Promise<ProxyInfo[]>;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.2 IBrowserProvider(仅浏览器进程生命周期,不含自动化)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// 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 与 open 解耦) */
|
|||
|
|
attachProxy(profile: ProfileInfo, proxy: ProxyInfo): Promise<void>;
|
|||
|
|
/** 启动浏览器进程并打开 URL(最终 BrowserAutomationService 通过 sessionName 操作) */
|
|||
|
|
open(profile: ProfileInfo, url?: string): Promise<void>;
|
|||
|
|
/** 关闭浏览器进程 */
|
|||
|
|
close(profile: ProfileInfo): Promise<void>;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.3 Provider 实现职责
|
|||
|
|
|
|||
|
|
| 实现 | 关键行为 |
|
|||
|
|
|---|---|
|
|||
|
|
| `LocalProxyProvider` | acquire 直接返回 `mode='local'` 标记的 ProxyInfo(不连出站代理);healthCheck 走宿主机 https://www.baidu.com |
|
|||
|
|
| `TianqiProxyProvider`(占位) | acquire/release 抛 `NotImplemented` 但不阻塞模块加载;healthCheck 同上;文件头部注释列出对接 TODO |
|
|||
|
|
| **`PlainChromiumProvider`**(★ S1 主实现) | create → 在 `dataDir/geo/profiles/` 下生成 `geo-{accountId}` profile 目录 + 写一个 `geo-proxy-{accountId}.json` 代理配置;attachProxy → 重写代理 JSON;open → exec `playwright-cli -s={session} open {url} --persistent --profile={profileDir} --headed --config={configPath}`;close → exec `playwright-cli -s={session} close`;delete → `playwright-cli -s={session} delete-data` + 删除配置文件 |
|
|||
|
|
| `BitBrowserStub` | 占位。未来实现:调 BitBrowser Local API 启动 profile 拿到 CDP endpoint,playwright-cli 通过 attach 模式连进去做自动化 |
|
|||
|
|
| `AntBrowserStub` | 占位。等用户提供源码后实现 |
|
|||
|
|
| `AdsPowerStub` | 占位。仅声明 name + 抛 NotImplemented |
|
|||
|
|
|
|||
|
|
### 6.4 BrowserAutomationService(统一自动化层,所有 Provider 共用)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// service/browser_automation.ts
|
|||
|
|
@Provide()
|
|||
|
|
export class BrowserAutomationService {
|
|||
|
|
/** 列出指定域名的 cookie。底层 exec:playwright-cli -s={session} cookie-list --domain={domain} --raw */
|
|||
|
|
async getCookies(sessionName: string, domain?: string): Promise<Cookie[]>;
|
|||
|
|
|
|||
|
|
/** 保存完整登录态到文件。底层 exec:playwright-cli -s={session} state-save {path} */
|
|||
|
|
async saveState(sessionName: string, filePath: string): Promise<void>;
|
|||
|
|
|
|||
|
|
/** 恢复登录态。底层 exec:playwright-cli -s={session} state-load {path} */
|
|||
|
|
async loadState(sessionName: string, filePath: string): Promise<void>;
|
|||
|
|
|
|||
|
|
/** S3 后续会增加 click / type / snapshot 等自动化方法 */
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**关键设计**:`BrowserAutomationService` 与 `IBrowserProvider` 完全解耦。无论 provider 启动的是普通 Chromium 还是 BitBrowser/ant-browser,只要进程存在且 `playwright-cli -s={session}` 能命中(或 attach 进去),自动化层无差别工作。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 7. 核心数据流
|
|||
|
|
|
|||
|
|
### 7.1 创建账号(同时分配 IP + Profile)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
前端 accounts.vue → POST /admin/geo/account/add
|
|||
|
|
↓
|
|||
|
|
service/account.ts.add(dto):
|
|||
|
|
await dataSource.transaction(async manager => {
|
|||
|
|
// 1. 申请 IP
|
|||
|
|
const proxyDto = dto.ipMode === 'local'
|
|||
|
|
? { provider: 'local', mode: 'local' }
|
|||
|
|
: { provider: 'tianqi', mode: 'third_party', region: dto.region };
|
|||
|
|
const ipInfo = await proxyService.acquire(proxyDto);
|
|||
|
|
const ipEntity = await proxyService.persist(manager, ipInfo);
|
|||
|
|
|
|||
|
|
// 2. 申请 Profile
|
|||
|
|
const profileInfo = await browserService.create({
|
|||
|
|
provider: dto.browserProvider ?? 'bitbrowser',
|
|||
|
|
...dto.fingerprint
|
|||
|
|
});
|
|||
|
|
const profileEntity = await browserService.persist(manager, profileInfo);
|
|||
|
|
|
|||
|
|
// 3. Provider 侧挂代理
|
|||
|
|
await browserService.attachProxy(profileEntity, ipEntity);
|
|||
|
|
|
|||
|
|
// 4. 创建 account
|
|||
|
|
const account = manager.create(GeoAccount, {
|
|||
|
|
...dto, proxyId: ipEntity.id, browserProfileId: profileEntity.id
|
|||
|
|
});
|
|||
|
|
return manager.save(account);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 失败补偿:transaction 回滚 + Provider 端
|
|||
|
|
catch (e) {
|
|||
|
|
if (ipExternal) await proxyProvider.release(ipExternal);
|
|||
|
|
if (profileExternal) await browserProvider.delete(profileExternal);
|
|||
|
|
throw e;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.2 启动浏览器环境
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
前端 [启动登录] → POST /admin/geo/account/launch { id, url? }
|
|||
|
|
↓
|
|||
|
|
service/account.ts.launch(id, url?):
|
|||
|
|
const account = repo.findOneOrFail(id);
|
|||
|
|
const profile = profileRepo.findOneOrFail(account.browserProfileId);
|
|||
|
|
const targetUrl = url || PLATFORM_URLS[account.platform]; // 如 https://www.xiaohongshu.com
|
|||
|
|
await browserProvider.open(profile.sessionName, targetUrl);
|
|||
|
|
// playwright-cli 会弹出有头浏览器窗口,用户在里面登录
|
|||
|
|
await profileRepo.update(profile.id, { status: 'running', lastOpenAt: new Date() });
|
|||
|
|
return { sessionName: profile.sessionName };
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.3 抓 Cookie
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
前端轮询/手动触发 → POST /admin/geo/account/captureCookies { id, domains? }
|
|||
|
|
↓
|
|||
|
|
service/account.ts.captureCookies(id, domains?):
|
|||
|
|
const account = ...findOneOrFail(id);
|
|||
|
|
const profile = ...findOneOrFail(account.browserProfileId);
|
|||
|
|
if (profile.status !== 'running') throw new Error('Browser not running, call launch first');
|
|||
|
|
|
|||
|
|
// 自动化层走 BrowserAutomationService(与底层是哪种浏览器无关)
|
|||
|
|
const domain = domains?.[0];
|
|||
|
|
const cookies = await browserAutomationService.getCookies(profile.sessionName, domain);
|
|||
|
|
if (cookies.length === 0) {
|
|||
|
|
return { captured: 0 };
|
|||
|
|
}
|
|||
|
|
const encrypted = geoEncryptService.encrypt(JSON.stringify(cookies));
|
|||
|
|
await accountRepo.update(id, {
|
|||
|
|
cookies: encrypted,
|
|||
|
|
cookieCapturedAt: new Date(),
|
|||
|
|
loginStatus: 'logged_in',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 同时保存完整登录态(cookie + localStorage)到文件,方便后续 loadState 复活会话
|
|||
|
|
const statePath = `${dataDir}/geo/states/geo-state-${id}.json`;
|
|||
|
|
await browserAutomationService.saveState(profile.sessionName, statePath);
|
|||
|
|
|
|||
|
|
return { captured: cookies.length };
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.4 IP 健康检查(Neta task 模块 cron)
|
|||
|
|
|
|||
|
|
Neta 的 task 模块通过 `task_info` 表 + `TaskLocalService`(基于 `cron` npm 包)调度。注册方式:在 `task_info` 表中 INSERT 一条记录,`service` 字段指向要调用的 service 方法路径。
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
注册(通过 mcp__mysql__execute INSERT 到 task_info 表):
|
|||
|
|
name: 'GEO IP 健康检查'
|
|||
|
|
service: 'geo.proxy_ip.healthCheckAll'
|
|||
|
|
type: 0 # 0=cron 表达式
|
|||
|
|
cron: '0 0 */6 * * *' # 每 6 小时
|
|||
|
|
status: 1 # 启用
|
|||
|
|
|
|||
|
|
service/proxy_ip.ts:
|
|||
|
|
async healthCheckAll() {
|
|||
|
|
const ips = await this.proxyIpEntity.find({ where: { status: 'active' } });
|
|||
|
|
for (const ip of ips) {
|
|||
|
|
const provider = this.getProvider(ip.provider);
|
|||
|
|
const result = await 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} (${ip.host}) health check failed`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// S1 仅 logger.warn,不入告警表(S4 才有 geo_alert)
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.5 解绑/换 IP
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
service/account.ts.rebindIp(accountId, newIpId):
|
|||
|
|
await dataSource.transaction(async manager => {
|
|||
|
|
const account = await manager.findOneOrFail(GeoAccount, accountId);
|
|||
|
|
const oldIp = await manager.findOne(GeoProxyIp, account.proxyId);
|
|||
|
|
const newIp = await manager.findOneOrFail(GeoProxyIp, newIpId);
|
|||
|
|
if (newIp.bindAccountId) throw new Error('IP 已被其他账号绑定');
|
|||
|
|
|
|||
|
|
if (oldIp) {
|
|||
|
|
await manager.update(GeoProxyIp, oldIp.id, { bindAccountId: null, status: 'unbound' });
|
|||
|
|
}
|
|||
|
|
await manager.update(GeoProxyIp, newIp.id, { bindAccountId: accountId, status: 'active' });
|
|||
|
|
await manager.update(GeoAccount, accountId, { proxyId: newIp.id });
|
|||
|
|
|
|||
|
|
const profile = await manager.findOneOrFail(GeoBrowserProfile, account.browserProfileId);
|
|||
|
|
await browserProvider.attachProxy(profile.externalProfileId, toProxyInfo(newIp));
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.6 删除账号
|
|||
|
|
|
|||
|
|
Neta 现有模块不使用 TypeORM `softRemove`(BaseEntity 没有 `@DeleteDateColumn`)。geo 模块采用**逻辑删除**:account 和 profile 设 `status='deleted'`,与 Neta 其他模块风格一致。
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
service/account.ts.deleteAccount(id):
|
|||
|
|
await dataSource.transaction(async manager => {
|
|||
|
|
const account = await manager.findOneOrFail(GeoAccount, { where: { id } });
|
|||
|
|
const ip = account.proxyId
|
|||
|
|
? await manager.findOne(GeoProxyIp, { where: { id: account.proxyId } })
|
|||
|
|
: null;
|
|||
|
|
const profile = account.browserProfileId
|
|||
|
|
? await manager.findOne(GeoBrowserProfile, { where: { id: account.browserProfileId } })
|
|||
|
|
: null;
|
|||
|
|
|
|||
|
|
if (profile) {
|
|||
|
|
await browserProvider.delete(profile.externalProfileId).catch(() => {/* 容忍外部失败 */});
|
|||
|
|
await manager.update(GeoBrowserProfile, profile.id, { status: 'deleted', accountId: null });
|
|||
|
|
}
|
|||
|
|
if (ip) {
|
|||
|
|
await manager.update(GeoProxyIp, ip.id, { bindAccountId: null, status: 'unbound' });
|
|||
|
|
}
|
|||
|
|
await manager.update(GeoAccount, id, { status: 'deleted', proxyId: null, browserProfileId: null });
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 8. 错误处理
|
|||
|
|
|
|||
|
|
| 故障 | 处理 |
|
|||
|
|
|---|---|
|
|||
|
|
| playwright-cli 未安装 | `PlaywrightCliProvider` 启动时检测 `playwright-cli --version`,失败抛 `BrowserProviderUnavailable`,前端弹窗提示安装 |
|
|||
|
|
| 天启 API 超时 | 重试 3 次(指数退避),仍失败 → `ProxyAcquireFailed`,前端提示切 local 或重试 |
|
|||
|
|
| Cookie 抓取空 | 不写库,返回 `captured: 0`,前端提示用户先在打开的浏览器里完成登录 |
|
|||
|
|
| 事务中途失败 | DB 自动回滚 + Provider 端补偿 release/delete |
|
|||
|
|
| 并发竞争 IP | UQ 索引兜底;service 层先 `lockMode: 'pessimistic_write'` 锁住 IP |
|
|||
|
|
| 浏览器进程残留 | `close` 失败时 logger.error 但不阻塞业务,运维侧定期清理 |
|
|||
|
|
| 加密密钥丢失 | `GeoEncryptService` 复用 `SKILL_SECRET_KEY`/`APP_SECRET` 环境变量派生密钥,丢失时抛 `EncryptionKeyMissing` |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 9. 测试策略
|
|||
|
|
|
|||
|
|
| 层 | 方法 |
|
|||
|
|
|---|---|
|
|||
|
|
| **Provider 单元** | LocalProxyProvider / BitBrowserProvider 走 mock fetch;TianqiProvider 验证抛 NotImplemented |
|
|||
|
|
| **Service 单元** | jest + sqlite,验证编排逻辑、事务回滚、补偿动作触发 |
|
|||
|
|
| **Controller 集成** | jest + supertest,跑 add → launch → captureCookies 一遍 |
|
|||
|
|
| **手工冒烟** | 真机 BitBrowser + 真小红书账号:创建 → 启动 → 登录 → 抓 cookie → loginStatus 验证 |
|
|||
|
|
|
|||
|
|
测试不在 S1 范围放真账号 token,所有真实凭据只在手工冒烟阶段使用。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 10. 验收标准
|
|||
|
|
|
|||
|
|
1. ✅ 后端 `pnpm dev` 启动,TypeORM 自动建好 `geo_account` `geo_proxy_ip` `geo_browser_profile` 三张表(可用 `mcp__mysql__list_tables` 验证)
|
|||
|
|
2. ✅ Cool Admin 自动 CRUD 接口可用:`/admin/geo/{account,proxy_ip,browser_profile}/{add,delete,update,info,list,page}` 全部返回 200
|
|||
|
|
3. ✅ 自定义接口可用:`/admin/geo/account/launch` `/captureCookies` `/rebindIp`、`/admin/geo/proxy_ip/healthCheck` `/batchImport`、`/admin/geo/browser_profile/open` `/close`
|
|||
|
|
4. ✅ `mcp__mysql__query` 能查到 `base_sys_menu` 中 `🌍 GEO` 顶级菜单 + 3 个子菜单 + 全部按钮权限
|
|||
|
|
5. ✅ 前端登录后菜单出现 `🌍 GEO` + 3 个子项;点击进入对应页面
|
|||
|
|
6. ✅ 在前端能完整跑通:
|
|||
|
|
- 创建一个 Local IP,能跑 healthCheck 看到延迟数字
|
|||
|
|
- 创建一个 playwright-cli profile,能 open 弹出有头浏览器窗口
|
|||
|
|
- 创建一个账号(platform=xiaohongshu),自动绑定 IP+Profile
|
|||
|
|
- 点击「启动登录」弹出有头浏览器,登录小红书后点「抓取 Cookie」,loginStatus 变 logged_in,cookies 字段非空且加密
|
|||
|
|
7. ✅ 删除账号:DB 中 IP 置 unbound 但记录保留;Profile 状态置 deleted;account 状态置 deleted(逻辑删除,非 softRemove)
|
|||
|
|
8. ✅ 强绑定约束:尝试给已绑账号的 IP 重新绑给别人,DB 报唯一约束错(前端友好提示)
|
|||
|
|
9. ✅ playwright-cli 未安装时,前端 open 操作返回 `BrowserProviderUnavailable` 错误并弹窗
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 11. 交付物清单
|
|||
|
|
|
|||
|
|
| 类别 | 文件 |
|
|||
|
|
|---|---|
|
|||
|
|
| **后端 entity** | `entity/account.ts` `entity/proxy_ip.ts` `entity/browser_profile.ts` |
|
|||
|
|
| **后端 controller** | `controller/admin/account.ts` `proxy_ip.ts` `browser_profile.ts` |
|
|||
|
|
| **后端 service** | `service/account.ts` `service/proxy_ip.ts` `service/browser_profile.ts` `service/browser_automation.ts`(统一自动化层) `service/encrypt.ts`(GeoEncryptService) |
|
|||
|
|
| **Provider** | `provider/proxy/{interface,local,tianqi}.ts` `provider/browser/{interface,plain_chromium,bitbrowser.stub,ant_browser.stub,adspower.stub}.ts` |
|
|||
|
|
| **配置** | `modules/geo/config.ts`(`entities.ts` 由 `cool entity` 自动生成,无需手动改) |
|
|||
|
|
| **菜单** | `mcp__mysql__execute` 注入 `base_sys_menu`(4 条 menu + 12+ 条 perms) |
|
|||
|
|
| **前端** | `modules/geo/{config.ts, views/{accounts,proxies,browser-profiles,dashboard}.vue, components/}` |
|
|||
|
|
| **测试** | `test/modules/geo/**` 单元+集成 |
|
|||
|
|
| **文档** | 本 spec + 后续 plan |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 12. 风险与依赖
|
|||
|
|
|
|||
|
|
| 风险/依赖 | 缓解 |
|
|||
|
|
|---|---|
|
|||
|
|
| playwright-cli 未安装 | Provider 启动时检测 + 前端友好提示 + 文档说明安装步骤(`npm i -g @playwright/cli@latest`) |
|
|||
|
|
| 天启 HTTP 文档迟到 | TianqiProvider 占位实现可走通整体流程,文档到位后只改一文件 |
|
|||
|
|
| `synchronize: true` 在 prod 危险 | Neta 现有约定 prod 禁用,S1 不改前提 |
|
|||
|
|
| Cookie 抓取目标域不全 | captureCookies 接受 `domains?: string[]` 参数 |
|
|||
|
|
| Playwright 与代理兼容性 | playwright-cli `--config` 支持 proxy 配置;S1 验证 local + http proxy 两种场景 |
|
|||
|
|
| 测试环境没有 playwright-cli | Provider 抽象允许 mock child_process.exec;CI 用 mock |
|
|||
|
|
| 深度指纹伪装 | S1 不做(playwright-cli 不支持 canvas/webgl 伪装);等 ant-browser 源码到位后新增 AntBrowserProvider |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 13. 范围红线(不在 S1)
|
|||
|
|
|
|||
|
|
- ❌ 平台特定登录流程(QR 轮询/账密表单/短信) → S3
|
|||
|
|
- ❌ 浏览/发布/评论/指标采集 → S3
|
|||
|
|
- ❌ 关键词、内容、Schema → S2
|
|||
|
|
- ❌ AI 引用监测 → S4
|
|||
|
|
- ❌ 多模态/数字人/爆款拆解 → S5(暂不规划)
|
|||
|
|
- ❌ Skill 包对外暴露给 NetaClaw Agent → S3 起做
|
|||
|
|
- ❌ WebSocket 实时启动日志 → S3 评估是否需要
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 14. 工作流
|
|||
|
|
|
|||
|
|
完成本 spec 后:
|
|||
|
|
|
|||
|
|
1. user 复核本文档
|
|||
|
|
2. 调用 `superpowers:writing-plans` 生成 `docs/superpowers/plans/2026-05-03-geo-s1-infrastructure-plan.md`
|
|||
|
|
3. user 复核 plan
|
|||
|
|
4. `superpowers:executing-plans` + TDD 实施
|
|||
|
|
5. `superpowers:verification-before-completion` 逐条核对验收标准
|
|||
|
|
6. `superpowers:requesting-code-review`
|
|||
|
|
7. 更新主路线图:S1 状态 → 已完成;启动 S2 brainstorming
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 15. 变更日志
|
|||
|
|
|
|||
|
|
| 日期 | 变更 |
|
|||
|
|
|---|---|
|
|||
|
|
| 2026-05-03 | 初稿 |
|
|||
|
|
| 2026-05-03 | 架构审查修复 8 项:①entities.ts 自动生成不手动改 ②自建 GeoEncryptService 替代直接调用 SkillSecretService ③定时任务改用 task_info 表注册 ④provider/ 目录加说明 ⑤删除前端 service/ 目录 ⑥逻辑删除替代 softRemove ⑦BitBrowser 端口从 config/env 读取 ⑧account/profile status 枚举加 deleted |
|
|||
|
|
| 2026-05-03 | 浏览器层重构:主实现从 BitBrowserProvider 改为 PlaywrightCliProvider(复用 Neta 已有 playwright-cli skill);BitBrowser 降为 stub;Entity 字段调整(externalProfileId→sessionName,cdpEndpoint→profileDir/configPath);接口增加 getCookies/saveState/loadState;不再依赖 playwright Node.js 库 |
|
|||
|
|
| 2026-05-03 | 浏览器层二次重构:澄清"自动化工具"与"浏览器进程"是两个正交层。新增 `BrowserAutomationService`(统一 playwright-cli 包装层)。`IBrowserProvider` 缩减为只管浏览器进程生命周期(去掉 getCookies/saveState/loadState)。S1 主实现重命名为 `PlainChromiumProvider`(playwright-cli + Chromium),BitBrowser/AntBrowser/AdsPower 全部 stub,等具体浏览器需求到位时只增加 provider 不改自动化层 |
|