- 前端创建页折叠区块:平台/品牌/版本/语言/时区/硬件并发/关闭伪装维度,留空使用默认 - 后端 accountRequest 增加 fingerprint,校验前置 400(避免账号已建、绑定失败的中间态) - seed 服务端从账号派生、proxy 由网络出口管理,两者不接受表单值 - 补建端点支持携带指纹重试;start 自愈补建保持零值指纹 - PG 集成测试:指纹落库/非法值拒绝不建号/补建指纹重试
This commit is contained in:
@@ -27,8 +27,8 @@ func soleGateway(ctx context.Context, store HubStore) (hub.Gateway, error) {
|
||||
}
|
||||
|
||||
// ensureAccountEnvironment 幂等补建账号环境:已绑定(任意出口)原样返回;
|
||||
// 未绑定时以 alias=账号 ID、派生 seed、直连出口创建。
|
||||
func ensureAccountEnvironment(ctx context.Context, store HubStore, accountID string) (hub.EnvironmentContext, bool, error) {
|
||||
// 未绑定时以 alias=账号 ID、派生 seed、指定指纹、直连出口创建。
|
||||
func ensureAccountEnvironment(ctx context.Context, store HubStore, accountID string, fingerprint hub.Fingerprint) (hub.EnvironmentContext, bool, error) {
|
||||
environment, err := store.GetEnvironmentContextForAccount(ctx, accountID)
|
||||
if err == nil {
|
||||
return environment, false, nil
|
||||
@@ -42,7 +42,7 @@ func ensureAccountEnvironment(ctx context.Context, store HubStore, accountID str
|
||||
}
|
||||
// seed 由 CreateBoundEnv 从账号 bigint id + 1000 派生(数字主键)。
|
||||
return store.CreateBoundEnv(ctx, hub.Env{
|
||||
Alias: accountID, Name: accountID, Gateway: gateway.Name,
|
||||
Alias: accountID, Name: accountID, Gateway: gateway.Name, Fingerprint: fingerprint,
|
||||
}, accountID, "")
|
||||
}
|
||||
|
||||
@@ -50,7 +50,8 @@ func ensureAccountEnvironment(ctx context.Context, store HubStore, accountID str
|
||||
func startAccountEnvironment(ctx context.Context, store HubStore, accountID string) error {
|
||||
environment, err := store.GetEnvironmentContextForAccount(ctx, accountID)
|
||||
if errors.Is(err, hub.ErrNotFound) {
|
||||
environment, _, err = ensureAccountEnvironment(ctx, store, accountID)
|
||||
// start 自愈补建时创建表单不可得,零值指纹 = 派生 seed + 浏览器默认参数。
|
||||
environment, _, err = ensureAccountEnvironment(ctx, store, accountID, hub.Fingerprint{})
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
accountdomain "git.ipao.vip/rogee/creator-hub/internal/account"
|
||||
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// 创建社媒账号的指纹浏览器环境表单:自定义指纹随创建请求落库;
|
||||
// seed 由服务端从账号派生、proxy 由出口体系管理(客户端值被忽略/清空)。
|
||||
func TestAccountCreateAcceptsFingerprintForm(t *testing.T) {
|
||||
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
|
||||
}
|
||||
ctx := context.Background()
|
||||
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
|
||||
accountStore, err := accountdomain.Open(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = accountStore.Close() })
|
||||
hubStore, err := hub.Open(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = hubStore.Close() })
|
||||
gateway := &fakeGateway{token: "unit-test-gateway-token"}
|
||||
gatewayServer := httptest.NewServer(gateway.handler(t))
|
||||
t.Cleanup(gatewayServer.Close)
|
||||
app := fiber.New()
|
||||
RegisterAccountRoutes(app, accountStore, hubStore, &testCredentialBridge{values: map[string]string{}})
|
||||
if _, err := hubStore.CreateGateway(ctx, "gw-main", gatewayServer.URL, gateway.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response := do(app, http.MethodPost, "/api/phase-a/accounts",
|
||||
`{"name":"指纹账号","platform":"douyin","platform_account_key":"key-fp-1","cookies":"sessionid=1",
|
||||
"fingerprint":{"seed":42,"platform":"windows","platform_version":"10.0.0","brand":"Chrome","brand_version":"132.0.6834.159",
|
||||
"hardware_concurrency":8,"lang":"zh-CN","accept_lang":"zh-CN,en-US","timezone":"Asia/Shanghai",
|
||||
"proxy_server":"socks5://proxy.example:1080","disable_spoofing":"canvas,gpu"}}`)
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 create account with fingerprint, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil || created.ID == "" {
|
||||
t.Fatalf("create payload: %s err=%v", response.Body.String(), err)
|
||||
}
|
||||
auditDB, err := sql.Open("pgx", databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = auditDB.Close() })
|
||||
var accountRowID int64
|
||||
if err := auditDB.QueryRowContext(ctx, `SELECT id FROM social_account WHERE account_id = $1`, created.ID).Scan(&accountRowID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
environment, err := hubStore.GetEnvironmentContext(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fingerprint := environment.Fingerprint
|
||||
if fingerprint.Platform != "windows" || fingerprint.PlatformVersion != "10.0.0" ||
|
||||
fingerprint.Brand != "Chrome" || fingerprint.BrandVersion != "132.0.6834.159" ||
|
||||
fingerprint.HardwareConcurrency != 8 || fingerprint.Lang != "zh-CN" ||
|
||||
fingerprint.AcceptLang != "zh-CN,en-US" || fingerprint.Timezone != "Asia/Shanghai" ||
|
||||
fingerprint.DisableSpoofing != "canvas,gpu" {
|
||||
t.Fatalf("fingerprint not persisted as submitted: %#v", fingerprint)
|
||||
}
|
||||
if fingerprint.Seed != accountRowID+1000 || fingerprint.ProxyServer != "" {
|
||||
t.Fatalf("seed must be server-derived and proxy cleared: %#v (row id %d)", fingerprint, accountRowID)
|
||||
}
|
||||
|
||||
// 非法指纹值 → 400,账号不落库(校验前置,无创建后绑定失败的中间态)。
|
||||
if response := do(app, http.MethodPost, "/api/phase-a/accounts",
|
||||
`{"name":"坏指纹","platform":"douyin","platform_account_key":"key-fp-2","fingerprint":{"platform":"android"}}`); response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid fingerprint, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
var invalidCount int
|
||||
if err := auditDB.QueryRowContext(ctx, `SELECT count(*) FROM social_account WHERE platform_account_key = 'key-fp-2'`).Scan(&invalidCount); err != nil || invalidCount != 0 {
|
||||
t.Fatalf("invalid fingerprint must not create account: rows=%d err=%v", invalidCount, err)
|
||||
}
|
||||
|
||||
// 幂等补建端点可携带同一指纹表单重试(创建时绑定失败的场景)。
|
||||
retry := do(app, http.MethodPost, "/api/phase-a/accounts",
|
||||
`{"name":"补建账号","platform":"douyin","platform_account_key":"key-fp-3","fingerprint":{"timezone":"Asia/Shanghai"}}`)
|
||||
if retry.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 create account for rebind retry, got %d: %s", retry.Code, retry.Body.String())
|
||||
}
|
||||
var rebindCreated struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(retry.Body.Bytes(), &rebindCreated); err != nil || rebindCreated.ID == "" {
|
||||
t.Fatalf("rebind create payload: %s err=%v", retry.Body.String(), err)
|
||||
}
|
||||
// 幂等补建端点可携带创建时未落库的指纹重试:先删除环境模拟"创建时绑定失败",补建后指纹落库。
|
||||
if err := hubStore.DeleteAccountEnvironment(ctx, rebindCreated.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rebind := do(app, http.MethodPost, "/api/phase-a/accounts/"+rebindCreated.ID+"/environment",
|
||||
`{"fingerprint":{"platform":"linux","lang":"en-US"}}`)
|
||||
if rebind.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 environment rebind with fingerprint, got %d: %s", rebind.Code, rebind.Body.String())
|
||||
}
|
||||
reboundEnvironment, err := hubStore.GetEnvironmentContext(ctx, rebindCreated.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 补建以传入指纹为准(时区为空 = 创建时的时区不保留)。
|
||||
if reboundEnvironment.Fingerprint.Platform != "linux" || reboundEnvironment.Fingerprint.Lang != "en-US" || reboundEnvironment.Fingerprint.Timezone != "" {
|
||||
t.Fatalf("rebind fingerprint mismatch: %#v", reboundEnvironment.Fingerprint)
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,12 @@ import (
|
||||
)
|
||||
|
||||
type accountRequest struct {
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
PlatformAccountKey string `json:"platform_account_key"`
|
||||
Tags []string `json:"tags"`
|
||||
Cookies string `json:"cookies"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
PlatformAccountKey string `json:"platform_account_key"`
|
||||
Tags []string `json:"tags"`
|
||||
Cookies string `json:"cookies"`
|
||||
Fingerprint hub.Fingerprint `json:"fingerprint"`
|
||||
}
|
||||
|
||||
// RegisterAccountRoutes exposes account lifecycle routes (create with auto-binding/list/detail/补建/start/pause/resume/revoke + audit).
|
||||
@@ -29,6 +30,12 @@ func RegisterAccountRoutes(app *fiber.App, store *accountdomain.Store, runtimeSt
|
||||
if err := decodePhaseA(c, &input); err != nil {
|
||||
return phaseAError(c, err)
|
||||
}
|
||||
// 指纹表单校验前置:非法值直接 400,避免账号已建、环境绑定失败的中间态。
|
||||
input.Fingerprint.ProxyServer = ""
|
||||
input.Fingerprint.DisableNonProxiedUDP = false
|
||||
if err := input.Fingerprint.Validate(); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "fingerprint: " + err.Error()})
|
||||
}
|
||||
tags := input.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
@@ -54,7 +61,7 @@ func RegisterAccountRoutes(app *fiber.App, store *accountdomain.Store, runtimeSt
|
||||
}
|
||||
// 账号即环境:创建即绑定(幂等)。绑定失败透传原因与账号 ID,客户端可用幂等补建端点重试。
|
||||
if runtimeStore != nil {
|
||||
if _, _, err := ensureAccountEnvironment(c.Context(), runtimeStore, accountID); err != nil {
|
||||
if _, _, err := ensureAccountEnvironment(c.Context(), runtimeStore, accountID, input.Fingerprint); err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(map[string]string{
|
||||
"error": err.Error(), "reason_code": "environment_binding_failed", "account_id": accountID,
|
||||
})
|
||||
@@ -110,12 +117,26 @@ func RegisterAccountRoutes(app *fiber.App, store *accountdomain.Store, runtimeSt
|
||||
if runtimeStore == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(map[string]string{"error": "environment store unavailable"})
|
||||
}
|
||||
// 幂等补建:空体保持零值指纹(seed 仍由账号派生);可携带创建时未落库的指纹表单重试。
|
||||
var rebind struct {
|
||||
Fingerprint hub.Fingerprint `json:"fingerprint"`
|
||||
}
|
||||
if len(c.Body()) > 0 {
|
||||
if err := decodePhaseA(c, &rebind); err != nil {
|
||||
return phaseAError(c, err)
|
||||
}
|
||||
}
|
||||
rebind.Fingerprint.ProxyServer = ""
|
||||
rebind.Fingerprint.DisableNonProxiedUDP = false
|
||||
if err := rebind.Fingerprint.Validate(); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "fingerprint: " + err.Error()})
|
||||
}
|
||||
unlock, err := lockAccountResources(c.Context(), runtimeStore, c.Params("id"))
|
||||
if err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
defer unlock()
|
||||
environment, created, err := ensureAccountEnvironment(c.Context(), runtimeStore, c.Params("id"))
|
||||
environment, created, err := ensureAccountEnvironment(c.Context(), runtimeStore, c.Params("id"), rebind.Fingerprint)
|
||||
if err != nil {
|
||||
return hubError(c, err)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
// 创建社媒账号:语义对齐 web.archived AccountCreatePage + AccountCreateForm。
|
||||
// cookies 非必填:留空代表创建后走扫码登录。创建成功跳编辑页。
|
||||
// 折叠区块:指纹浏览器环境(可选);seed 由后端从账号派生,代理由网络出口管理,均不在表单内。
|
||||
import { useState } from 'react';
|
||||
import { history } from '@umijs/max';
|
||||
import { Alert, Button, Card, Form, Input, Select } from 'antd';
|
||||
import { Alert, Button, Card, Col, Collapse, Flex, Form, Input, InputNumber, Row, Select, Typography } from 'antd';
|
||||
import { create } from '@/services/api';
|
||||
import { conflictMessage, platforms } from '@/utils/helpers';
|
||||
|
||||
interface FingerprintValues {
|
||||
platform?: string;
|
||||
platform_version?: string;
|
||||
brand?: string;
|
||||
brand_version?: string;
|
||||
hardware_concurrency?: number;
|
||||
lang?: string;
|
||||
accept_lang?: string;
|
||||
timezone?: string;
|
||||
disable_spoofing?: string[];
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
platform: string;
|
||||
platform_account_key: string;
|
||||
tags?: string[];
|
||||
cookies?: string;
|
||||
fingerprint?: FingerprintValues;
|
||||
}
|
||||
|
||||
const spoofingOptions = [
|
||||
{ value: 'font', label: '字体' },
|
||||
{ value: 'audio', label: '音频' },
|
||||
{ value: 'canvas', label: 'Canvas' },
|
||||
{ value: 'clientrects', label: 'ClientRects' },
|
||||
{ value: 'gpu', label: 'GPU' },
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -31,6 +53,21 @@ export default function Page() {
|
||||
};
|
||||
// cookies 非必填:留空代表创建后走扫码登录,凭据由后续同步链路补齐
|
||||
if (values.cookies?.trim()) data.cookies = values.cookies.trim();
|
||||
// 指纹表单:只提交非空项;留空项由后端使用浏览器默认值
|
||||
const fp = values.fingerprint;
|
||||
if (fp) {
|
||||
const fingerprint: Record<string, unknown> = {};
|
||||
if (fp.platform) fingerprint.platform = fp.platform;
|
||||
if (fp.platform_version?.trim()) fingerprint.platform_version = fp.platform_version.trim();
|
||||
if (fp.brand) fingerprint.brand = fp.brand;
|
||||
if (fp.brand_version?.trim()) fingerprint.brand_version = fp.brand_version.trim();
|
||||
if (fp.hardware_concurrency) fingerprint.hardware_concurrency = fp.hardware_concurrency;
|
||||
if (fp.lang?.trim()) fingerprint.lang = fp.lang.trim();
|
||||
if (fp.accept_lang?.trim()) fingerprint.accept_lang = fp.accept_lang.trim();
|
||||
if (fp.timezone?.trim()) fingerprint.timezone = fp.timezone.trim();
|
||||
if (fp.disable_spoofing?.length) fingerprint.disable_spoofing = fp.disable_spoofing.join(',');
|
||||
if (Object.keys(fingerprint).length) data.fingerprint = fingerprint;
|
||||
}
|
||||
const result = await create('accounts', data);
|
||||
const id = result?.data?.id ?? result?.id;
|
||||
if (!id) throw new Error('创建账号未返回账号 ID');
|
||||
@@ -67,11 +104,95 @@ export default function Page() {
|
||||
>
|
||||
<Input.TextArea maxLength={8192} rows={3} placeholder="可选,如:sessionid=value; token=value" />
|
||||
</Form.Item>
|
||||
<Collapse
|
||||
style={{ marginBottom: 24 }}
|
||||
items={[
|
||||
{
|
||||
key: 'fingerprint',
|
||||
label: '指纹浏览器环境(可选)',
|
||||
children: (
|
||||
<>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
留空项使用浏览器默认值;seed 由系统按账号自动派生并保证唯一,代理由网络出口统一管理,均不在此配置。
|
||||
</Typography.Text>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name={['fingerprint', 'platform']} label="平台伪装" extra="浏览器上报的操作系统">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="默认"
|
||||
options={[
|
||||
{ value: 'windows', label: 'Windows' },
|
||||
{ value: 'linux', label: 'Linux' },
|
||||
{ value: 'macos', label: 'macOS' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name={['fingerprint', 'brand']} label="浏览器品牌" extra="UA 中的浏览器品牌">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="默认"
|
||||
options={[
|
||||
{ value: 'Chrome', label: 'Chrome' },
|
||||
{ value: 'Edge', label: 'Edge' },
|
||||
{ value: 'Opera', label: 'Opera' },
|
||||
{ value: 'Vivaldi', label: 'Vivaldi' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name={['fingerprint', 'platform_version']} label="平台版本" extra="如 10.0.0">
|
||||
<Input maxLength={32} placeholder="默认" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name={['fingerprint', 'brand_version']} label="品牌版本" extra="如 132.0.6834.159">
|
||||
<Input maxLength={32} placeholder="默认" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name={['fingerprint', 'lang']} label="语言" extra="如 zh-CN">
|
||||
<Input maxLength={16} placeholder="默认" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name={['fingerprint', 'accept_lang']} label="接受语言" extra="逗号分隔,如 zh-CN,en-US">
|
||||
<Input maxLength={256} placeholder="默认" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name={['fingerprint', 'timezone']} label="时区" extra="IANA 时区,如 Asia/Shanghai">
|
||||
<Input maxLength={64} placeholder="默认" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name={['fingerprint', 'hardware_concurrency']} label="硬件并发数" extra="CPU 逻辑核数,1-128">
|
||||
<InputNumber min={1} max={128} precision={0} style={{ width: '100%' }} placeholder="默认" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item
|
||||
name={['fingerprint', 'disable_spoofing']}
|
||||
label="关闭伪装维度"
|
||||
extra="关闭指定维度的指纹伪装,保持浏览器真实特征"
|
||||
>
|
||||
<Select mode="multiple" allowClear placeholder="默认全部伪装" options={spoofingOptions} />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Form.Item>
|
||||
<Button onClick={() => history.push('/accounts')}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={busy} style={{ marginLeft: 8 }}>
|
||||
{busy ? '创建中…' : '创建账号'}
|
||||
</Button>
|
||||
<Flex gap={8}>
|
||||
<Button onClick={() => history.push('/accounts')}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={busy}>
|
||||
{busy ? '创建中…' : '创建账号'}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user