Files
creator-hub/internal/account/store.go
T
rogee c567080248 refactor(api): 删除运营链路与 runtime_use_lease
删除任务链(drafts/confirmations/tasks/attempts)、creator 运营链(策略/事件/操作/会话/消息/监听器/素材/关系)、runtime_use_lease 机制及其 API/UI;taskstate 包与对应测试一并移除。
2026-09-28 17:14:00 +08:00

607 lines
22 KiB
Go

package account
import (
"context"
"crypto/rand"
"database/sql"
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
_ "github.com/jackc/pgx/v5/stdlib"
)
//go:embed migrations/001_phase_a.sql
var migration001 string
//go:embed migrations/036_runtime_use_leases.sql
var migration036 string
var (
ErrConflict = errors.New("resource conflicts with existing state")
ErrInvalid = errors.New("invalid phase A input")
ErrNotFound = errors.New("resource not found")
ErrAccountCreationUnknown = errors.New("account creation result is unknown")
idPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`)
refPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
platformKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$`)
credentialKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}/[A-Za-z0-9][A-Za-z0-9._/-]{0,126}$`)
eventPattern = regexp.MustCompile(`^[a-z0-9_]{1,64}$`)
)
type Store struct {
db *sql.DB
accountCommit func(*sql.Tx) error
}
type Account struct {
ID string `json:"id"`
Name string `json:"name"`
Platform string `json:"platform"`
PlatformAccountKey string `json:"platform_account_key"`
Tags []string `json:"tags"`
Cookies string `json:"-"`
CredentialReference CredentialReference `json:"-"`
CredentialKey string `json:"-"`
AuthorizationStatus string `json:"authorization_status"`
RuntimeStatus string `json:"runtime_status"`
Version int64 `json:"version"`
}
type CredentialReference struct {
ID string
Provider string
}
type CredentialBridge interface {
// Store may fail after a partial write; Delete must be idempotent for compensation.
Store(context.Context, CredentialReference, string, string) error
Delete(context.Context, CredentialReference, string) error
}
type CredentialResolver interface {
Resolve(context.Context, CredentialReference, string) ([]byte, error)
}
type ReadinessError struct {
Reason string
Unavailable bool
}
func (e *ReadinessError) Error() string { return e.Reason }
type AuditEvent struct {
ID int64 `json:"id"`
EventType string `json:"event_type"`
AccountID string `json:"account_id,omitempty"`
ConfirmationID string `json:"confirmation_id,omitempty"`
ConfirmationVersion int64 `json:"confirmation_version,omitempty"`
AttemptID string `json:"attempt_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
BrowserEnvAlias string `json:"browser_env_alias,omitempty"`
NetworkExitID string `json:"network_exit_id,omitempty"`
RuntimeInstanceID string `json:"runtime_instance_id,omitempty"`
BindingVersion int64 `json:"binding_version,omitempty"`
Actor string `json:"actor,omitempty"`
ReasonCode string `json:"reason_code,omitempty"`
OperationID string `json:"operation_id,omitempty"`
Action string `json:"action,omitempty"`
Outcome string `json:"outcome,omitempty"`
Details json.RawMessage `json:"details"`
CreatedAt time.Time `json:"created_at"`
}
type AuditFilter struct {
AccountID, TaskID, AttemptID, BrowserEnvAlias, NetworkExitID, EventType string
From, To *time.Time
Page, PageSize int
}
type AuditPage struct {
Data []AuditEvent `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
func Open(ctx context.Context, databaseURL string) (*Store, error) {
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return nil, errors.New("open phase A database")
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(2)
db.SetConnMaxIdleTime(5 * time.Minute)
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, errors.New("connect to phase A database")
}
store := &Store{db: db}
if err := store.migrate(ctx); err != nil {
db.Close()
return nil, err
}
return store, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
func (s *Store) migrate(ctx context.Context) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return errors.New("begin schema migration")
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1542738011)`); err != nil {
return errors.New("lock schema migration")
}
if _, err := tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migration (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
return errors.New("create schema migration table")
}
for _, migration := range []struct {
version int
sql string
}{{1, migration001}, {36, migration036}} {
var applied bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil {
return errors.New("read schema migration state")
}
if applied {
continue
}
if _, err := tx.ExecContext(ctx, migration.sql); err != nil {
return fmt.Errorf("apply schema migration %d: %w", migration.version, err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migration (version) VALUES ($1)`, migration.version); err != nil {
return fmt.Errorf("record schema migration %d", migration.version)
}
}
if err := tx.Commit(); err != nil {
return errors.New("commit schema migration")
}
return nil
}
func (s *Store) CreateAccount(ctx context.Context, account Account, credentials CredentialBridge) (err error) {
if account.Tags == nil {
account.Tags = []string{}
}
if !validAccount(account) || credentials == nil {
return ErrInvalid
}
// 空凭据(扫码登录场景)不写 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
}
stored = true
}
defer func() {
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"))
}
}
}()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return errors.New("begin account transaction")
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `INSERT INTO credential_reference (id, provider, reference_key) VALUES ($1, $2, $3)`,
account.CredentialReference.ID, account.CredentialReference.Provider, account.CredentialKey); err != nil {
return publicDatabaseError(err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO social_account
(id, credential_reference_id, name, platform, platform_account_key, tags, authorization_kind, authorization_status, status)
VALUES ($1, $2, $3, $4, $5, $6, 'owned', 'authorized', 'paused')`, account.ID, account.CredentialReference.ID,
account.Name, account.Platform, account.PlatformAccountKey, account.Tags); err != nil {
return publicDatabaseError(err)
}
if err := appendAudit(ctx, tx, "account_created", "account_created", account.ID, "", 0, "", "", map[string]string{"platform": account.Platform}); err != nil {
return err
}
var commitErr error
if s.accountCommit != nil {
commitErr = s.accountCommit(tx)
} else {
commitErr = tx.Commit()
}
if commitErr == nil {
return nil
}
if !commitKnownRolledBack(commitErr) {
return ErrAccountCreationUnknown
}
return errors.New("commit account transaction")
}
func commitKnownRolledBack(err error) bool {
if errors.Is(err, pgx.ErrTxCommitRollback) {
return true
}
var postgresError *pgconn.PgError
return errors.As(err, &postgresError)
}
func (s *Store) ListAccounts(ctx context.Context) ([]Account, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT account.id, account.name, account.platform, account.platform_account_key, account.tags,
account.authorization_status, account.status, account.version
FROM social_account account
ORDER BY account.created_at, account.id`)
if err != nil {
return nil, errors.New("read accounts")
}
defer rows.Close()
accounts := []Account{}
for rows.Next() {
account, err := scanAccount(rows)
if err != nil {
return nil, err
}
accounts = append(accounts, account)
}
return accounts, rows.Err()
}
func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) {
if !idPattern.MatchString(id) {
return Account{}, ErrInvalid
}
return scanAccount(s.db.QueryRowContext(ctx, `
SELECT account.id, account.name, account.platform, account.platform_account_key, account.tags,
account.authorization_status, account.status, account.version
FROM social_account account
WHERE account.id = $1`, id))
}
func (s *Store) ResolveAccountCredential(ctx context.Context, id string, resolver CredentialResolver) ([]byte, error) {
if !idPattern.MatchString(id) || resolver == nil {
return nil, ErrInvalid
}
var reference CredentialReference
var key string
if err := s.db.QueryRowContext(ctx, `
SELECT credential.id, credential.provider, credential.reference_key
FROM social_account account
JOIN credential_reference credential ON credential.id = account.credential_reference_id
WHERE account.id = $1`, id).Scan(&reference.ID, &reference.Provider, &key); err != nil {
return nil, rowError(err)
}
value, err := resolver.Resolve(ctx, reference, key)
if err != nil {
return nil, err
}
if len(value) == 0 {
return nil, ErrInvalid
}
return value, nil
}
type accountScanner interface{ Scan(...any) error }
func scanAccount(row accountScanner) (Account, error) {
var account Account
var tags pgtype.FlatArray[string]
if err := row.Scan(&account.ID, &account.Name, &account.Platform, &account.PlatformAccountKey, pgtype.NewMap().SQLScanner(&tags),
&account.AuthorizationStatus, &account.RuntimeStatus, &account.Version); err != nil {
return Account{}, rowError(err)
}
account.Tags = []string(tags)
return account, nil
}
func validAccount(account Account) bool {
if !idPattern.MatchString(account.ID) || strings.TrimSpace(account.Name) != account.Name || account.Name == "" ||
!utf8.ValidString(account.Name) || utf8.RuneCountInString(account.Name) > 128 ||
!platformKeyPattern.MatchString(account.PlatformAccountKey) || len(account.Tags) > 20 ||
len(account.Cookies) > 8192 || !refPattern.MatchString(account.CredentialReference.ID) ||
!credentialKeyPattern.MatchString(account.CredentialKey) ||
(account.CredentialReference.Provider != "os_keyring" && account.CredentialReference.Provider != "secret_manager") {
return false
}
switch account.Platform {
case "douyin":
default:
return false
}
for _, tag := range account.Tags {
if tag == "" || strings.TrimSpace(tag) != tag || !utf8.ValidString(tag) || utf8.RuneCountInString(tag) > 32 {
return false
}
}
// 空凭据合法(扫码登录场景);非空时才校验 Cookie Header 格式
if account.Cookies != "" {
if _, err := http.ParseCookie(account.Cookies); err != nil {
return false
}
}
return true
}
func (s *Store) PauseAccount(ctx context.Context, accountID string) error {
return s.disableAccount(ctx, accountID, false)
}
func (s *Store) RevokeAccount(ctx context.Context, accountID string) error {
return s.disableAccount(ctx, accountID, true)
}
func (s *Store) disableAccount(ctx context.Context, accountID string, revoke bool) error {
if !idPattern.MatchString(accountID) {
return ErrInvalid
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return errors.New("begin account state transaction")
}
defer tx.Rollback()
var version int64
var authorizationStatus, runtimeStatus string
if err := tx.QueryRowContext(ctx, `SELECT version, authorization_status, status FROM social_account WHERE id = $1 FOR UPDATE`, accountID).
Scan(&version, &authorizationStatus, &runtimeStatus); err != nil {
return rowError(err)
}
unchanged := (revoke && authorizationStatus == "revoked") || (!revoke && runtimeStatus == "paused")
if !unchanged {
if err := tx.QueryRowContext(ctx, `
UPDATE social_account
SET authorization_status = CASE WHEN $2 THEN 'revoked' ELSE authorization_status END,
status = 'paused', paused_at = now(), revoked_at = CASE WHEN $2 THEN now() ELSE revoked_at END,
version = version + 1, updated_at = now()
WHERE id = $1 RETURNING version`, accountID, revoke).Scan(&version); err != nil {
return errors.New("change account state")
}
}
reason := "account_paused"
if revoke {
reason = "account_revoked"
}
if !unchanged {
eventType := "account_paused"
if revoke {
eventType = "account_revoked"
}
if err := appendAudit(ctx, tx, eventType, reason, accountID, "", 0, "", "", map[string]any{
"account_version": version,
}); err != nil {
return err
}
}
return commit(tx)
}
func (s *Store) ResumeAccount(ctx context.Context, accountID string) error {
if !idPattern.MatchString(accountID) {
return ErrInvalid
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return errors.New("begin resume transaction")
}
defer tx.Rollback()
var authorizationStatus, runtimeStatus string
if err := tx.QueryRowContext(ctx, `SELECT authorization_status, status FROM social_account WHERE id = $1 FOR UPDATE`, accountID).
Scan(&authorizationStatus, &runtimeStatus); err != nil {
return rowError(err)
}
if authorizationStatus == "revoked" {
return ErrConflict
}
var ready bool
if err := tx.QueryRowContext(ctx, `
SELECT EXISTS (
SELECT 1 FROM environment_binding binding
LEFT JOIN network_exit network ON network.id = binding.network_exit_id
WHERE binding.account_id = $1 AND (binding.network_exit_id IS NULL OR network.health_status = 'healthy')
AND NOT binding.runtime_cleanup_pending
AND NOT EXISTS (
SELECT 1 FROM runtime_instance runtime
WHERE runtime.binding_id = binding.id AND runtime.released_at IS NULL
)
)`, accountID).Scan(&ready); err != nil {
return errors.New("validate account binding")
}
if !ready {
return ErrConflict
}
if runtimeStatus == "active" {
return commit(tx)
}
var version int64
if err := tx.QueryRowContext(ctx, `
UPDATE social_account SET status = 'active', paused_at = NULL, version = version + 1, updated_at = now()
WHERE id = $1 RETURNING version`, accountID).Scan(&version); err != nil {
return errors.New("resume account")
}
if err := appendAudit(ctx, tx, "account_resumed", "account_resumed", accountID, "", 0, "", "", map[string]any{"account_version": version}); err != nil {
return err
}
return commit(tx)
}
func (s *Store) Audit(ctx context.Context) ([]AuditEvent, error) {
page, err := s.ListAudit(ctx, AuditFilter{Page: 1, PageSize: 1000})
return page.Data, err
}
func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) (AuditPage, error) {
if (filter.AccountID != "" && !idPattern.MatchString(filter.AccountID)) ||
(filter.TaskID != "" && !refPattern.MatchString(filter.TaskID)) ||
(filter.AttemptID != "" && !refPattern.MatchString(filter.AttemptID)) ||
(filter.BrowserEnvAlias != "" && !refPattern.MatchString(filter.BrowserEnvAlias)) ||
(filter.NetworkExitID != "" && !refPattern.MatchString(filter.NetworkExitID)) ||
(filter.EventType != "" && !eventPattern.MatchString(filter.EventType)) || filter.Page < 1 ||
filter.PageSize < 1 || filter.PageSize > 1000 || (filter.From != nil && filter.To != nil && filter.From.After(*filter.To)) {
return AuditPage{}, ErrInvalid
}
var from, to any
if filter.From != nil {
from = *filter.From
}
if filter.To != nil {
to = *filter.To
}
var total int
err := s.db.QueryRowContext(ctx, `
SELECT count(*) FROM audit_event
WHERE ($1 = '' OR account_id = $1) AND ($2 = '' OR task_id = $2) AND ($3 = '' OR attempt_id = $3)
AND ($4 = '' OR browser_env_alias = $4) AND ($5 = '' OR network_exit_id = $5) AND ($6 = '' OR event_type = $6)
AND ($7::timestamptz IS NULL OR created_at >= $7) AND ($8::timestamptz IS NULL OR created_at <= $8)`,
filter.AccountID, filter.TaskID, filter.AttemptID, filter.BrowserEnvAlias, filter.NetworkExitID, filter.EventType, from, to).Scan(&total)
if err != nil {
return AuditPage{}, errors.New("count audit events")
}
rows, err := s.db.QueryContext(ctx, `
SELECT id, event_type, account_id, confirmation_id, confirmation_version, attempt_id, task_id,
browser_env_alias, network_exit_id, runtime_instance_id, binding_version, actor, reason_code,
operation_id, action, outcome, details, created_at
FROM audit_event
WHERE ($1 = '' OR account_id = $1) AND ($2 = '' OR task_id = $2) AND ($3 = '' OR attempt_id = $3)
AND ($4 = '' OR browser_env_alias = $4) AND ($5 = '' OR network_exit_id = $5) AND ($6 = '' OR event_type = $6)
AND ($7::timestamptz IS NULL OR created_at >= $7) AND ($8::timestamptz IS NULL OR created_at <= $8)
ORDER BY created_at DESC, id DESC LIMIT $9 OFFSET $10`, filter.AccountID, filter.TaskID, filter.AttemptID,
filter.BrowserEnvAlias, filter.NetworkExitID, filter.EventType, from, to, filter.PageSize, (filter.Page-1)*filter.PageSize)
if err != nil {
return AuditPage{}, errors.New("read audit events")
}
defer rows.Close()
events := []AuditEvent{}
for rows.Next() {
var event AuditEvent
var accountID, confirmationID, attemptID, taskID, browserEnvAlias, networkExitID sql.NullString
var runtimeInstanceID, actor, reasonCode, operationID, action, outcome sql.NullString
var confirmationVersion, bindingVersion sql.NullInt64
if err := rows.Scan(&event.ID, &event.EventType, &accountID, &confirmationID, &confirmationVersion, &attemptID, &taskID,
&browserEnvAlias, &networkExitID, &runtimeInstanceID, &bindingVersion, &actor, &reasonCode,
&operationID, &action, &outcome, &event.Details, &event.CreatedAt); err != nil {
return AuditPage{}, errors.New("decode audit event")
}
event.AccountID, event.ConfirmationID, event.ConfirmationVersion = accountID.String, confirmationID.String, confirmationVersion.Int64
event.AttemptID, event.TaskID = attemptID.String, taskID.String
event.BrowserEnvAlias, event.NetworkExitID = browserEnvAlias.String, networkExitID.String
event.RuntimeInstanceID, event.BindingVersion = runtimeInstanceID.String, bindingVersion.Int64
event.Actor, event.ReasonCode = actor.String, reasonCode.String
event.OperationID, event.Action, event.Outcome = operationID.String, action.String, outcome.String
event.Details = safeDetails(event.Details)
events = append(events, event)
}
if err := rows.Err(); err != nil {
return AuditPage{}, errors.New("read audit events")
}
return AuditPage{Data: events, Total: total, Page: filter.Page, PageSize: filter.PageSize}, nil
}
func safeDetails(raw json.RawMessage) json.RawMessage {
var value any
if json.Unmarshal(raw, &value) != nil {
return json.RawMessage(`{}`)
}
value = allowDetails(value)
encoded, err := json.Marshal(value)
if err != nil {
return json.RawMessage(`{}`)
}
return encoded
}
var allowedDetailKeys = map[string]bool{
"platform": true, "draft_id": true, "draft_version": true, "account_version": true,
"tasks_held": true, "attempts_interrupted": true, "verification_result": true,
"state": true, "worker_id": true, "mock_outcome": true,
}
func allowDetails(value any) any {
switch value := value.(type) {
case map[string]any:
for key, child := range value {
if !allowedDetailKeys[strings.ToLower(key)] {
delete(value, key)
continue
}
value[key] = allowDetails(child)
}
case []any:
for index, child := range value {
value[index] = allowDetails(child)
}
}
return value
}
func appendAudit(ctx context.Context, tx *sql.Tx, eventType, reasonCode, accountID, confirmationID string, confirmationVersion int64, attemptID, taskID string, details any) error {
if details == nil {
details = map[string]any{}
}
encoded, err := json.Marshal(details)
if err != nil {
return errors.New("encode audit details")
}
_, err = tx.ExecContext(ctx, `
INSERT INTO audit_event
(event_type, account_id, confirmation_id, confirmation_version, attempt_id, task_id,
browser_env_alias, network_exit_id, runtime_instance_id, binding_version, actor, reason_code, details)
SELECT $1, NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, 0), NULLIF($6, ''), NULLIF($7, ''),
binding.browser_env_alias, binding.network_exit_id, runtime.id, binding.version, 'local-user', $2, $8
FROM (VALUES (1)) AS singleton(value)
LEFT JOIN environment_binding binding ON binding.account_id = NULLIF($3, '')
LEFT JOIN runtime_instance runtime ON runtime.binding_id = binding.id AND runtime.released_at IS NULL`,
eventType, reasonCode, accountID, confirmationID, confirmationVersion, attemptID, taskID, encoded)
if err != nil {
return errors.New("append audit event")
}
return nil
}
func newID() string {
var value [16]byte
_, _ = rand.Read(value[:])
return hex.EncodeToString(value[:])
}
func NewAccountID() string { return "account-" + newID()[:24] }
func commit(tx *sql.Tx) error {
if err := tx.Commit(); err != nil {
return errors.New("commit transaction")
}
return nil
}
func rowError(err error) error {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return publicDatabaseError(err)
}
func publicDatabaseError(err error) error {
if err == nil {
return nil
}
var postgresError *pgconn.PgError
if errors.As(err, &postgresError) && (postgresError.Code == "23505" || postgresError.Code == "23503" || postgresError.Code == "23514") {
return ErrConflict
}
return errors.New("phase A persistence operation failed")
}