diff --git a/internal/phasea/store.go b/internal/phasea/store.go index 989129e..9ddeeb5 100644 --- a/internal/phasea/store.go +++ b/internal/phasea/store.go @@ -273,15 +273,20 @@ func (s *Store) CreateAccount(ctx context.Context, account Account, credentials if !validAccount(account) || credentials == nil { return ErrInvalid } - if err := credentials.Store(ctx, account.CredentialReference, account.CredentialKey, account.Cookies); err != nil { - storeErr := errors.New("store account credential") - if cleanupErr := credentials.Delete(context.WithoutCancel(ctx), account.CredentialReference, account.CredentialKey); cleanupErr != nil { - storeErr = errors.Join(storeErr, errors.New("delete incomplete account credential")) + // 空凭据(扫码登录场景)不写 keyring:凭据留待后续登录/同步链路补齐 + stored := false + if account.Cookies != "" { + if err := credentials.Store(ctx, account.CredentialReference, account.CredentialKey, account.Cookies); err != nil { + storeErr := errors.New("store account credential") + if cleanupErr := credentials.Delete(context.WithoutCancel(ctx), account.CredentialReference, account.CredentialKey); cleanupErr != nil { + storeErr = errors.Join(storeErr, errors.New("delete incomplete account credential")) + } + return storeErr } - return storeErr + stored = true } defer func() { - if err != nil && !errors.Is(err, ErrAccountCreationUnknown) { + if stored && err != nil && !errors.Is(err, ErrAccountCreationUnknown) { if cleanupErr := credentials.Delete(context.WithoutCancel(ctx), account.CredentialReference, account.CredentialKey); cleanupErr != nil { err = errors.Join(err, errors.New("delete orphaned account credential")) } @@ -393,8 +398,13 @@ func validAccount(account Account) bool { return false } } - _, err := http.ParseCookie(account.Cookies) - return err == nil + // 空凭据合法(扫码登录场景);非空时才校验 Cookie Header 格式 + if account.Cookies != "" { + if _, err := http.ParseCookie(account.Cookies); err != nil { + return false + } + } + return true } func (s *Store) CreateDraft(ctx context.Context, draft Draft) error { diff --git a/internal/phasea/store_test.go b/internal/phasea/store_test.go index e60ed5b..f07660a 100644 --- a/internal/phasea/store_test.go +++ b/internal/phasea/store_test.go @@ -79,6 +79,11 @@ func TestValidationRejectsInvalidInputsBeforePersistence(t *testing.T) { t.Fatalf("supported platform rejected: %s", platform) } } + empty := valid + empty.Cookies = "" + if !validAccount(empty) { + t.Fatal("empty cookies must stay valid (scan-to-login account)") + } for name, mutate := range map[string]func(*Account){ "id": func(account *Account) { account.ID = "INVALID" }, "name": func(account *Account) { account.Name = " " }, @@ -725,6 +730,38 @@ func TestPhaseAOfflineWorkflow(t *testing.T) { } } +func TestCreateAccountWithoutCookiesSkipsCredentialStore(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() + store, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + applyHubMigrationsForPhaseATest(t, store) + if _, err := store.db.ExecContext(ctx, ` + TRUNCATE audit_event, execution_attempt, operation_task, confirmation, content_draft, + runtime_instance, environment_binding, network_exit, social_account, credential_reference, + browser_env, browser_image, gateway RESTART IDENTITY CASCADE`); err != nil { + t.Fatal(err) + } + credentials := &testCredentialBridge{values: map[string]string{}} + account := Account{ID: "account-no-cookies", Name: "扫码账号", Platform: "douyin", PlatformAccountKey: "qr-login", + Tags: []string{}, Cookies: "", + CredentialReference: CredentialReference{ID: "account-no-cookies-cookies", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-no-cookies/cookies"} + if err := store.CreateAccount(ctx, account, credentials); err != nil { + t.Fatalf("creating an account without cookies failed: %v", err) + } + if _, stored := credentials.values[account.CredentialKey]; stored { + t.Fatal("empty cookies must not be written to the credential provider") + } + assertCount(t, store, `SELECT count(*) FROM social_account WHERE id = $1`, 1, account.ID) + assertCount(t, store, `SELECT count(*) FROM credential_reference WHERE id = $1`, 1, account.CredentialReference.ID) +} + func TestAccountCredentialCommitResult(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { diff --git a/web/src/AccountsPage.jsx b/web/src/AccountsPage.jsx index 6e6f1e2..d8b6035 100644 --- a/web/src/AccountsPage.jsx +++ b/web/src/AccountsPage.jsx @@ -1,269 +1,560 @@ -import { useMemo, useState } from 'react' -import { Link, useNavigate, useParams } from 'react-router' -import { useDataProvider, useList, useOne } from '@refinedev/core' +import { useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router"; +import { useDataProvider, useList, useOne } from "@refinedev/core"; import { - Alert, Button, Card, CardContent, ConfirmDialog, DetailList, Field, Input, Modal, PageHeader, - PageState, Select, StatusPill, Textarea, conflictMessage, -} from './lib/ui.jsx' -import { useTitle } from './lib/hooks.js' + Alert, + Button, + Card, + CardContent, + ConfirmDialog, + DetailList, + Field, + Input, + Modal, + PageHeader, + PageState, + Select, + StatusPill, + Textarea, + conflictMessage, +} from "./lib/ui.jsx"; +import { useTitle } from "./lib/hooks.js"; -const createInitial = { name: '', platform: '', platform_account_key: '', tags: '', cookies: '' } +const createInitial = { + name: "", + platform: "", + platform_account_key: "", + tags: "", + cookies: "", +}; const platforms = [ - { value: 'douyin', label: '抖音' }, - { value: 'xiaohongshu', label: '小红书' }, - { value: 'wechat-official', label: '公众号' }, - { value: 'kuaishou', label: '快手' }, -] + { value: "douyin", label: "抖音" }, + { value: "xiaohongshu", label: "小红书" }, + { value: "wechat-official", label: "公众号" }, + { value: "kuaishou", label: "快手" }, +]; const reasonText = { - account_revoked: '授权已撤销', - account_paused: '账号已暂停', - binding_missing: '未绑定运行环境', - network_exit_missing: '未绑定固定出口', - network_exit_unhealthy: '固定出口不健康', - runtime_stop_pending: '运行环境停止结果待确认', - runtime_missing: '运行环境未启动', - runtime_active: '仍有活动运行实例', - account_conflict: '账号状态已变化,请刷新后重试', - environment_unavailable: '环境状态未知', -} + account_revoked: "授权已撤销", + account_paused: "账号已暂停", + binding_missing: "未绑定运行环境", + network_exit_missing: "未绑定固定出口", + network_exit_unhealthy: "固定出口不健康", + runtime_stop_pending: "运行环境停止结果待确认", + runtime_missing: "运行环境未启动", + runtime_active: "仍有活动运行实例", + account_conflict: "账号状态已变化,请刷新后重试", + environment_unavailable: "环境状态未知", +}; // 账号可恢复性判定:授权、绑定、出口健康、账号暂停四层都要看。 export function accountReadiness(account, binding, bindingsError = false) { - if (account.authorization_status !== 'authorized') return { label: '授权已撤销', reason: 'account_revoked', canResume: false, ready: false } - if (bindingsError) return { label: '环境状态未知', reason: 'environment_unavailable', canResume: false, ready: false } - if (!binding) return { label: '未绑定运行环境', reason: 'binding_missing', canResume: false, ready: false } + if (account.authorization_status !== "authorized") + return { + label: "授权已撤销", + reason: "account_revoked", + canResume: false, + ready: false, + }; + if (bindingsError) + return { + label: "环境状态未知", + reason: "environment_unavailable", + canResume: false, + ready: false, + }; + if (!binding) + return { + label: "未绑定运行环境", + reason: "binding_missing", + canResume: false, + ready: false, + }; const blocked = binding.cleanup_pending - ? 'runtime_stop_pending' - : binding.network_exit_id && binding.network_exit_health !== 'healthy' - ? 'network_exit_unhealthy' - : '' - if (blocked) return { label: reasonText[blocked], reason: blocked, canResume: false, ready: false } - if (account.runtime_status === 'paused') { - const stopped = !binding.runtime_instance_id - return { label: stopped ? '资源就绪,可恢复' : '等待运行环境停止', reason: stopped ? '' : 'runtime_stop_pending', canResume: stopped, ready: false } + ? "runtime_stop_pending" + : binding.network_exit_id && binding.network_exit_health !== "healthy" + ? "network_exit_unhealthy" + : ""; + if (blocked) + return { + label: reasonText[blocked], + reason: blocked, + canResume: false, + ready: false, + }; + if (account.runtime_status === "paused") { + const stopped = !binding.runtime_instance_id; + return { + label: stopped ? "资源就绪,可恢复" : "等待运行环境停止", + reason: stopped ? "" : "runtime_stop_pending", + canResume: stopped, + ready: false, + }; } - const schedule = binding.schedule_block_reason || '' - return { label: schedule ? reasonText[schedule] || `不可调度:${schedule}` : '可调度', reason: schedule, canResume: false, ready: !schedule } + const schedule = binding.schedule_block_reason || ""; + return { + label: schedule + ? reasonText[schedule] || `不可调度:${schedule}` + : "可调度", + reason: schedule, + canResume: false, + ready: !schedule, + }; } function ReadinessPill({ readiness, paused }) { - const tone = readiness.ready ? 'success' : paused && readiness.canResume ? 'warning' : 'danger' - return + const tone = readiness.ready + ? "success" + : paused && readiness.canResume + ? "warning" + : "danger"; + return ; } function AccountCreateModal({ open, onClose, onSubmit, busy, error }) { - const [form, setForm] = useState(createInitial) - const update = (key, value) => setForm(current => ({ ...current, [key]: value })) - const valid = form.name.trim() && form.platform && form.platform_account_key.trim() && form.cookies.trim() + const [form, setForm] = useState(createInitial); + const update = (key, value) => + setForm((current) => ({ ...current, [key]: value })); + const valid = + form.name.trim() && form.platform && form.platform_account_key.trim(); async function submit(event) { - event.preventDefault() - if (!valid) return - const created = await onSubmit({ + event.preventDefault(); + if (!valid) return; + const data = { name: form.name.trim(), platform: form.platform.trim(), platform_account_key: form.platform_account_key.trim(), - tags: form.tags.split(/[,,]/).map(tag => tag.trim()).filter(Boolean), - cookies: form.cookies.trim(), - }) - if (created) setForm(createInitial) + tags: form.tags + .split(/[,,]/) + .map((tag) => tag.trim()) + .filter(Boolean), + }; + // cookies 非必填:留空代表创建后走扫码登录,凭据由后续同步链路补齐 + if (form.cookies.trim()) data.cookies = form.cookies.trim(); + const created = await onSubmit(data); + if (created) setForm(createInitial); } return ( - - - - }> + + + + + } + >
- {error ? {conflictMessage(error, '该平台的账号 ID 已存在;表单内容已保留。')} : null} + {error ? ( + + {conflictMessage(error, "该平台的账号 ID 已存在;表单内容已保留。")} + + ) : null}
- - update('name', event.target.value)} placeholder="如:店铺一号" /> + + update("name", event.target.value)} + placeholder="如:店铺一号" + /> - - update("platform", event.target.value)} + options={platforms} + placeholder="选择平台" + /> - - update('platform_account_key', event.target.value)} /> + + + update("platform_account_key", event.target.value) + } + /> - update('tags', event.target.value)} placeholder="如:主账号,直播" /> + update("tags", event.target.value)} + placeholder="如:主账号,直播" + /> - -