feat(api): 账号环境指纹更新端点——PUT /creator/accounts/:id/fingerprint 单独保存并按需重启
- Store.UpdateAccountFingerprint:更新账号绑定环境指纹,seed 保持账号派生值,携带代理直接拒绝(门禁对齐 CreateBoundEnv) - 运行中的环境先停后启以应用新指纹,未运行仅落库下次启动生效;账号资源锁防并发冲突 - accountEnvironmentView 内联 fingerprint 回显,编辑页预填数据源 - 覆盖:store 层 seed 保留/校验拒绝/JSON 往返、路由畸形入参、PG 端到端(保存/回显/持久化/400/404)
This commit is contained in:
@@ -523,6 +523,47 @@ func (s *Store) GetEnvironmentContextForAccount(ctx context.Context, accountID s
|
||||
return s.GetEnvironmentContext(ctx, alias)
|
||||
}
|
||||
|
||||
// UpdateAccountFingerprint 更新账号绑定环境的指纹参数并返回最新环境上下文。
|
||||
// seed 保持账号派生值不可改,代理由网络出口管理不可存(直连环境强制空代理)。
|
||||
func (s *Store) UpdateAccountFingerprint(ctx context.Context, accountID string, fingerprint Fingerprint) (EnvironmentContext, error) {
|
||||
// 门禁对齐 CreateBoundEnv:存储层拒绝携带代理的指纹(代理由网络出口管理,传错入口直接报错)。
|
||||
if !aliasPattern.MatchString(accountID) || fingerprint.ProxyServer != "" || fingerprint.Validate() != nil {
|
||||
return EnvironmentContext{}, ErrInvalid
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return EnvironmentContext{}, errors.New("begin account fingerprint update")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var alias string
|
||||
var encoded []byte
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
SELECT environment.alias, environment.fingerprint
|
||||
FROM browser_env environment
|
||||
JOIN social_account account ON account.id = environment.account_id
|
||||
WHERE account.account_id = $1
|
||||
FOR UPDATE OF environment`, accountID).Scan(&alias, &encoded)
|
||||
if err != nil {
|
||||
return EnvironmentContext{}, rowError(err)
|
||||
}
|
||||
var stored Fingerprint
|
||||
if err := json.Unmarshal(encoded, &stored); err != nil {
|
||||
return EnvironmentContext{}, errors.New("decode environment fingerprint")
|
||||
}
|
||||
fingerprint.Seed = stored.Seed
|
||||
updated, err := json.Marshal(fingerprint)
|
||||
if err != nil {
|
||||
return EnvironmentContext{}, errors.New("encode environment fingerprint")
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE browser_env SET fingerprint = $2 WHERE alias = $1`, alias, updated); err != nil {
|
||||
return EnvironmentContext{}, errors.New("update environment fingerprint")
|
||||
}
|
||||
if err := commitHub(tx); err != nil {
|
||||
return EnvironmentContext{}, err
|
||||
}
|
||||
return s.GetEnvironmentContext(ctx, alias)
|
||||
}
|
||||
|
||||
const runtimeUseLeaseDuration = time.Minute
|
||||
|
||||
// MissingRuntimeID 标记「创建结果未知」的清理代:网关侧无实物 ID 可供 fence,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package environment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedFingerprintAccount 造一条可直接建绑定的账号 + 网关,返回 store。
|
||||
func seedFingerprintAccount(t *testing.T, ctx context.Context, accountID string) *Store {
|
||||
t.Helper()
|
||||
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
|
||||
}
|
||||
databaseURL = isolatedDatabaseURL(t, databaseURL)
|
||||
store := openFullyMigratedHub(t, ctx, databaseURL)
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
if _, err := store.CreateGateway(ctx, "gw-fp", "http://127.0.0.1:8081", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx, `
|
||||
INSERT INTO social_account (account_id, credential_provider, credential_key, platform, platform_account_key, status)
|
||||
VALUES ($1, 'os_keyring', $2, 'mock', $1, 'paused')`, accountID, "creatorhub/"+accountID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func TestUpdateAccountFingerprintPreservesDerivedSeed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := seedFingerprintAccount(t, ctx, "fp-owner")
|
||||
if _, created, err := store.CreateBoundEnv(ctx, Env{Alias: "fp-owner", Name: "fp-owner", Gateway: "gw-fp", Fingerprint: Fingerprint{Timezone: "Asia/Shanghai"}}, "fp-owner", ""); err != nil || !created {
|
||||
t.Fatalf("create bound env: created=%v err=%v", created, err)
|
||||
}
|
||||
|
||||
updated, err := store.UpdateAccountFingerprint(ctx, "fp-owner", Fingerprint{
|
||||
Platform: "linux", Timezone: "Asia/Tokyo", Lang: "ja-JP",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update account fingerprint: %v", err)
|
||||
}
|
||||
if updated.Fingerprint.Platform != "linux" || updated.Fingerprint.Timezone != "Asia/Tokyo" || updated.Fingerprint.Lang != "ja-JP" {
|
||||
t.Fatalf("fingerprint fields not updated: %+v", updated.Fingerprint)
|
||||
}
|
||||
if updated.Fingerprint.Seed < 1 {
|
||||
t.Fatalf("derived seed must be preserved, got %d", updated.Fingerprint.Seed)
|
||||
}
|
||||
// 直连环境不可携带代理:更新入口必须清洗代理字段。
|
||||
if updated.Fingerprint.ProxyServer != "" || updated.Fingerprint.DisableNonProxiedUDP {
|
||||
t.Fatalf("proxy fields must stay empty: %+v", updated.Fingerprint)
|
||||
}
|
||||
// 更新对后续读取可见(落库持久化)。
|
||||
reloaded, err := store.GetEnvironmentContextForAccount(ctx, "fp-owner")
|
||||
if err != nil || reloaded.Alias != "fp-owner" {
|
||||
t.Fatalf("reload environment: %v", err)
|
||||
}
|
||||
if reloaded.Fingerprint.Timezone != "Asia/Tokyo" || reloaded.Fingerprint.Platform != "linux" {
|
||||
t.Fatalf("update not persisted: %+v", reloaded.Fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAccountFingerprintRejectsInvalidInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := seedFingerprintAccount(t, ctx, "fp-owner")
|
||||
if _, _, err := store.CreateBoundEnv(ctx, Env{Alias: "fp-owner", Name: "fp-owner", Gateway: "gw-fp"}, "fp-owner", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := store.UpdateAccountFingerprint(ctx, "fp-owner", Fingerprint{Platform: "android"}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected ErrInvalid for bad platform, got %v", err)
|
||||
}
|
||||
if _, err := store.UpdateAccountFingerprint(ctx, "fp-owner", Fingerprint{ProxyServer: "socks5://proxy.example:1080", Timezone: "Asia/Shanghai"}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("stored fingerprint proxy must be rejected, got %v", err)
|
||||
}
|
||||
if _, err := store.UpdateAccountFingerprint(ctx, "fp-owner", Fingerprint{HardwareConcurrency: -1}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected ErrInvalid for bad concurrency, got %v", err)
|
||||
}
|
||||
if _, err := store.UpdateAccountFingerprint(ctx, "missing-owner", Fingerprint{Timezone: "Asia/Shanghai"}); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound for unbound account, got %v", err)
|
||||
}
|
||||
// 失败更新不得污染落库值。
|
||||
current, err := store.GetEnvironmentContextForAccount(ctx, "fp-owner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.Fingerprint.Platform != "" || current.Fingerprint.Timezone != "" {
|
||||
t.Fatalf("failed update must not persist: %+v", current.Fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAccountFingerprintJSONRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := seedFingerprintAccount(t, ctx, "fp-owner")
|
||||
if _, _, err := store.CreateBoundEnv(ctx, Env{Alias: "fp-owner", Name: "fp-owner", Gateway: "gw-fp"}, "fp-owner", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.UpdateAccountFingerprint(ctx, "fp-owner", Fingerprint{
|
||||
Brand: "Edge", AcceptLang: "zh-CN,en-US", DisableSpoofing: "font,gpu", HardwareConcurrency: 8,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := store.GetEnvironmentContextForAccount(ctx, "fp-owner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Fingerprint.Brand != "Edge" || updated.Fingerprint.AcceptLang != "zh-CN,en-US" ||
|
||||
updated.Fingerprint.DisableSpoofing != "font,gpu" || updated.Fingerprint.HardwareConcurrency != 8 {
|
||||
t.Fatalf("complex fingerprint not persisted: %+v", updated.Fingerprint)
|
||||
}
|
||||
// 落库 JSON 必须可再次反序列化(browser_env.fingerprint jsonb 契约)。
|
||||
encoded, err := json.Marshal(updated.Fingerprint)
|
||||
if err != nil || !strings.Contains(string(encoded), `"brand":"Edge"`) {
|
||||
t.Fatalf("fingerprint json roundtrip: %s err=%v", encoded, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user