# 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)` 且语义是 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; } 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; release(externalId: string): Promise; healthCheck(p: ProxyInfo): Promise<{ ok: boolean; latencyMs: number }>; list?(): Promise; } ``` ### 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; } export interface ProfileInfo { sessionName: string; profileDir?: string; configPath?: string; userAgent: string; osPlatform: string; timezone: string; language: string; screenW: number; screenH: number; } export interface IBrowserProvider { readonly name: string; /** 创建 profile(写配置文件、注册指纹环境,不启动进程) */ create(opts: CreateOpts): Promise; /** 删除 profile 配置 */ delete(profile: ProfileInfo): Promise; /** 把代理写入 profile 配置(attachProxy 与 open 解耦) */ attachProxy(profile: ProfileInfo, proxy: ProxyInfo): Promise; /** 启动浏览器进程并打开 URL(最终 BrowserAutomationService 通过 sessionName 操作) */ open(profile: ProfileInfo, url?: string): Promise; /** 关闭浏览器进程 */ close(profile: ProfileInfo): Promise; } ``` ### 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; /** 保存完整登录态到文件。底层 exec:playwright-cli -s={session} state-save {path} */ async saveState(sessionName: string, filePath: string): Promise; /** 恢复登录态。底层 exec:playwright-cli -s={session} state-load {path} */ async loadState(sessionName: string, filePath: string): Promise; /** 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 不改自动化层 |