package creator import ( "context" "database/sql" "errors" "fmt" "strings" "time" "unicode/utf8" ) func (s *Store) EnsureAccountProfile(ctx context.Context, accountID string) error { if strings.TrimSpace(accountID) == "" { return ErrInvalid } _, err := s.db.ExecContext(ctx, ` INSERT INTO creator_account_profile (account_id) VALUES ($1) ON CONFLICT (account_id) DO NOTHING`, accountID) return databaseError(err) } func accountProfileQuery() string { return ` SELECT a.id, a.name, a.platform, a.platform_account_key, a.authorization_kind, a.authorization_status, a.status, p.login_username, p.password_configured, p.real_name_status, p.real_name, p.identity_number, p.note, p.business_status, p.big_account, p.reply_requirements, p.login_status, p.login_reason, p.login_checked_at, p.cooldown_seconds, p.updated_at FROM social_account a JOIN creator_account_profile p ON p.account_id = a.id WHERE a.id = $1` } func scanAccountProfile(scanner interface{ Scan(...any) error }) (AccountProfile, error) { var result AccountProfile var checkedAt sql.NullTime if err := scanner.Scan( &result.ID, &result.Name, &result.Platform, &result.PlatformAccountKey, &result.AuthorizationKind, &result.AuthorizationStatus, &result.RuntimeStatus, &result.LoginUsername, &result.PasswordConfigured, &result.RealNameStatus, &result.RealName, &result.IdentityNumber, &result.Note, &result.BusinessStatus, &result.BigAccount, &result.ReplyRequirements, &result.LoginStatus, &result.LoginReason, &checkedAt, &result.CooldownSeconds, &result.UpdatedAt, ); err != nil { return AccountProfile{}, err } result.LoginCheckedAt = nullableTime(checkedAt) return result, nil } func (s *Store) GetAccountProfile(ctx context.Context, accountID string) (AccountProfile, error) { if err := s.EnsureAccountProfile(ctx, accountID); err != nil { return AccountProfile{}, err } result, err := scanAccountProfile(s.db.QueryRowContext(ctx, accountProfileQuery(), accountID)) return result, rowError(err) } func (s *Store) UpdateAccountTags(ctx context.Context, accountID string, tags []string) ([]string, error) { accountID = strings.TrimSpace(accountID) if accountID == "" || validateCreatorTags(tags) != nil { return nil, ErrInvalid } if tags == nil { tags = []string{} } result, err := s.db.ExecContext(ctx, ` UPDATE social_account SET tags = $2, updated_at = now() WHERE id = $1`, accountID, tags) if err != nil { return nil, databaseError(err) } affected, err := result.RowsAffected() if err != nil { return nil, databaseError(err) } if affected != 1 { return nil, ErrNotFound } return tags, nil } func (s *Store) ListAccountProfiles(ctx context.Context) ([]AccountProfile, error) { if _, err := s.db.ExecContext(ctx, ` INSERT INTO creator_account_profile (account_id) SELECT id FROM social_account ON CONFLICT (account_id) DO NOTHING`); err != nil { return nil, databaseError(err) } rows, err := s.db.QueryContext(ctx, strings.Replace(accountProfileQuery(), "WHERE a.id = $1", "ORDER BY a.created_at DESC, a.id", 1)) if err != nil { return nil, fmt.Errorf("list creator account profiles: %w", err) } defer rows.Close() profiles := make([]AccountProfile, 0) for rows.Next() { profile, err := scanAccountProfile(rows) if err != nil { return nil, fmt.Errorf("decode creator account profile: %w", err) } profiles = append(profiles, profile) } return profiles, rows.Err() } func validateProfileUpdate(input AccountProfileUpdate) error { if input.RealNameStatus != "unknown" && input.RealNameStatus != "not_real_name" && input.RealNameStatus != "recorded" { return ErrInvalid } if input.BusinessStatus != "normal" && input.BusinessStatus != "muted" && input.BusinessStatus != "banned" && input.BusinessStatus != "deleted" { return ErrInvalid } if !validCooldownSeconds(input.CooldownSeconds) || utf8.RuneCountInString(input.LoginUsername) > 255 || utf8.RuneCountInString(input.RealName) > 100 || utf8.RuneCountInString(input.IdentityNumber) > 64 || utf8.RuneCountInString(input.Note) > 1000 || utf8.RuneCountInString(input.ReplyRequirements) > 4000 { return ErrInvalid } return nil } func (s *Store) UpdateAccountProfile(ctx context.Context, accountID string, input AccountProfileUpdate) (profile AccountProfile, returnErr error) { input.LoginUsername = strings.TrimSpace(input.LoginUsername) input.RealName = strings.TrimSpace(input.RealName) input.IdentityNumber = strings.TrimSpace(input.IdentityNumber) input.Note = strings.TrimSpace(input.Note) input.ReplyRequirements = strings.TrimSpace(input.ReplyRequirements) if err := validateProfileUpdate(input); err != nil { return AccountProfile{}, err } if err := s.EnsureAccountProfile(ctx, accountID); err != nil { return AccountProfile{}, err } passwordConfigured := false var secret SecretReference var secretKey, oldSecretID, oldSecretKey string committed := false if input.Password != "" { if s.secrets == nil { return AccountProfile{}, ErrUnavailable } secret = SecretReference{ID: newID("password"), Provider: "os_keyring"} secretKey = "creatorhub/" + accountID + "/password/" + secret.ID if err := s.secrets.Store(ctx, secret, secretKey, input.Password); err != nil { return AccountProfile{}, fmt.Errorf("store account password: %w", err) } passwordConfigured = true defer func() { if !committed && returnErr != nil { if cleanupErr := s.secrets.Delete(ctx, secret, secretKey); cleanupErr != nil { returnErr = errors.Join(returnErr, cleanupErr) } } }() } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return AccountProfile{}, fmt.Errorf("begin account profile update: %w", err) } defer tx.Rollback() var lockedID string if err := tx.QueryRowContext(ctx, `SELECT id FROM social_account WHERE id=$1 FOR UPDATE`, accountID).Scan(&lockedID); err != nil { return AccountProfile{}, rowError(err) } if input.Password != "" { err := tx.QueryRowContext(ctx, `SELECT secret_reference_id,secret_key FROM creator_account_password WHERE account_id=$1 FOR UPDATE`, accountID).Scan(&oldSecretID, &oldSecretKey) if err != nil && !errors.Is(err, sql.ErrNoRows) { return AccountProfile{}, databaseError(err) } } if input.BigAccount { var isSmall bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_relation WHERE small_account_id=$1)`, accountID).Scan(&isSmall); err != nil { return AccountProfile{}, databaseError(err) } if isSmall { return AccountProfile{}, ErrConflict } } _, err = tx.ExecContext(ctx, ` UPDATE creator_account_profile SET login_username = $2, password_configured = CASE WHEN $3 THEN true ELSE password_configured END, real_name_status = $4, real_name = $5, identity_number = $6, note = $7, business_status = $8, big_account = $9, reply_requirements = $10, cooldown_seconds = $11, updated_at = now() WHERE account_id = $1`, accountID, input.LoginUsername, passwordConfigured, input.RealNameStatus, input.RealName, input.IdentityNumber, input.Note, input.BusinessStatus, input.BigAccount, input.ReplyRequirements, input.CooldownSeconds) if err != nil { if passwordConfigured { if deleteErr := s.secrets.Delete(ctx, secret, secretKey); deleteErr != nil { return AccountProfile{}, fmt.Errorf("save account profile: %w; remove password after failure: %v", databaseError(err), deleteErr) } } return AccountProfile{}, databaseError(err) } if input.Password != "" { if _, err := tx.ExecContext(ctx, `INSERT INTO creator_account_password (account_id,secret_reference_id,secret_key) VALUES ($1,$2,$3) ON CONFLICT (account_id) DO UPDATE SET secret_reference_id=EXCLUDED.secret_reference_id,secret_key=EXCLUDED.secret_key,updated_at=now()`, accountID, secret.ID, secretKey); err != nil { return AccountProfile{}, databaseError(err) } } if !input.BigAccount || input.BusinessStatus != "normal" { if _, err := tx.ExecContext(ctx, `UPDATE creator_strategy SET enabled=false, updated_at=now() WHERE big_account_id=$1 OR execution_account_id=$1`, accountID); err != nil { return AccountProfile{}, databaseError(err) } } if err := tx.Commit(); err != nil { return AccountProfile{}, fmt.Errorf("commit account profile update: %w", err) } committed = true if input.Password != "" && oldSecretID != "" && (oldSecretID != secret.ID || oldSecretKey != secretKey) { if err := s.secrets.Delete(ctx, SecretReference{ID: oldSecretID, Provider: "os_keyring"}, oldSecretKey); err != nil { return AccountProfile{}, fmt.Errorf("replace account password: remove old secret: %w", err) } } if err := s.InvalidateListener(ctx, accountID, "账号配置变更"); err != nil { return AccountProfile{}, err } return s.GetAccountProfile(ctx, accountID) } func (s *Store) RecordLoginResult(ctx context.Context, accountID, status, reason, actualKey string) (LoginResult, error) { status = strings.TrimSpace(status) if status == "logged_in" { return LoginResult{}, ErrConflict } return s.recordLoginResult(ctx, accountID, status, reason, actualKey) } func (s *Store) RecordVerifiedLoginResult(ctx context.Context, accountID, actualKey string) (LoginResult, error) { actualKey = strings.TrimSpace(actualKey) if actualKey == "" { return LoginResult{}, ErrInvalid } return s.recordLoginResult(ctx, accountID, "logged_in", "", actualKey) } func (s *Store) recordLoginResult(ctx context.Context, accountID, status, reason, actualKey string) (LoginResult, error) { status = strings.TrimSpace(status) reason = strings.TrimSpace(reason) if status != "logged_in" && status != "needs_login" && status != "failed" && status != "manual_required" { return LoginResult{}, ErrInvalid } if len(actualKey) > 128 || utf8.RuneCountInString(reason) > 1000 { return LoginResult{}, ErrInvalid } profile, err := s.GetAccountProfile(ctx, accountID) if err != nil { return LoginResult{}, err } if status == "logged_in" && (actualKey == "" || actualKey != profile.PlatformAccountKey) { return LoginResult{}, ErrConflict } now := time.Now().UTC() _, err = s.db.ExecContext(ctx, ` UPDATE creator_account_profile SET login_status = $2, login_reason = $3, login_checked_at = $4, updated_at = $4 WHERE account_id = $1`, accountID, status, reason, now) if err != nil { return LoginResult{}, databaseError(err) } if status != "logged_in" { if _, err := s.db.ExecContext(ctx, `UPDATE creator_strategy SET enabled=false, updated_at=now() WHERE big_account_id=$1 OR execution_account_id=$1`, accountID); err != nil { return LoginResult{}, databaseError(err) } } if err := s.InvalidateListener(ctx, accountID, "登录状态变更"); err != nil { return LoginResult{}, err } return LoginResult{AccountID: accountID, Status: status, Reason: reason, ActualKey: actualKey, CheckedAt: now}, nil } func (s *Store) SetBigAccount(ctx context.Context, accountID string, enabled bool) (AccountProfile, error) { if accountID == "" { return AccountProfile{}, ErrInvalid } if err := s.EnsureAccountProfile(ctx, accountID); err != nil { return AccountProfile{}, err } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return AccountProfile{}, fmt.Errorf("begin creator big-account update: %w", err) } defer tx.Rollback() if enabled { var isSmall bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_relation WHERE small_account_id=$1)`, accountID).Scan(&isSmall); err != nil { return AccountProfile{}, databaseError(err) } if isSmall { return AccountProfile{}, ErrConflict } } if _, err := tx.ExecContext(ctx, `UPDATE creator_account_profile SET big_account = $2, updated_at = now() WHERE account_id = $1`, accountID, enabled); err != nil { return AccountProfile{}, databaseError(err) } if !enabled { if _, err := tx.ExecContext(ctx, `UPDATE creator_strategy SET enabled=false, updated_at=now() WHERE big_account_id=$1 OR execution_account_id=$1`, accountID); err != nil { return AccountProfile{}, databaseError(err) } } if err := tx.Commit(); err != nil { return AccountProfile{}, fmt.Errorf("commit creator big-account update: %w", err) } if err := s.InvalidateListener(ctx, accountID, "大小号模式变更"); err != nil { return AccountProfile{}, err } return s.GetAccountProfile(ctx, accountID) } func (s *Store) ListRelations(ctx context.Context, bigAccountID string) ([]Relation, error) { query := `SELECT big_account_id, small_account_id FROM creator_relation ORDER BY created_at, small_account_id` args := []any{} if bigAccountID != "" { query = `SELECT big_account_id, small_account_id FROM creator_relation WHERE big_account_id = $1 ORDER BY created_at, small_account_id` args = append(args, bigAccountID) } rows, err := s.db.QueryContext(ctx, query, args...) if err != nil { return nil, databaseError(err) } defer rows.Close() result := make([]Relation, 0) for rows.Next() { var relation Relation if err := rows.Scan(&relation.BigAccountID, &relation.SmallAccountID); err != nil { return nil, err } result = append(result, relation) } return result, rows.Err() } func (s *Store) SetRelation(ctx context.Context, bigAccountID, smallAccountID string, enabled bool) error { if bigAccountID == "" || smallAccountID == "" || bigAccountID == smallAccountID { return ErrInvalid } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin creator relation: %w", err) } defer tx.Rollback() // Lock both account rows in a stable order so relationship checks and role changes // cannot observe a half-updated account pair. first, second := bigAccountID, smallAccountID if first > second { first, second = second, first } rows, err := tx.QueryContext(ctx, `SELECT id FROM social_account WHERE id IN ($1,$2) ORDER BY id FOR UPDATE`, first, second) if err != nil { return databaseError(err) } defer rows.Close() count := 0 for rows.Next() { count++ } if err := rows.Err(); err != nil { return databaseError(err) } if count != 2 { return ErrNotFound } if err := rows.Close(); err != nil { return err } var bigPlatform, smallPlatform, bigAuthorizationKind, smallAuthorizationKind string if err := tx.QueryRowContext(ctx, `SELECT platform, authorization_kind FROM social_account WHERE id = $1`, bigAccountID).Scan(&bigPlatform, &bigAuthorizationKind); err != nil { return rowError(err) } if err := tx.QueryRowContext(ctx, `SELECT platform, authorization_kind FROM social_account WHERE id = $1`, smallAccountID).Scan(&smallPlatform, &smallAuthorizationKind); err != nil { return rowError(err) } if !ValidatePlatform(bigPlatform) || bigPlatform != smallPlatform { return ErrInvalid } if enabled && (bigAuthorizationKind != SourceOwned || smallAuthorizationKind != SourceOwned) { return ErrInvalid } var bigEnabled bool if err := tx.QueryRowContext(ctx, `SELECT COALESCE((SELECT big_account FROM creator_account_profile WHERE account_id=$1), false)`, bigAccountID).Scan(&bigEnabled); err != nil { return databaseError(err) } if enabled && !bigEnabled { return ErrConflict } if enabled { var smallIsBig, bigIsSmall bool if err := tx.QueryRowContext(ctx, `SELECT COALESCE((SELECT big_account FROM creator_account_profile WHERE account_id = $1), false)`, smallAccountID).Scan(&smallIsBig); err != nil { return rowError(err) } if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_relation WHERE small_account_id=$1)`, bigAccountID).Scan(&bigIsSmall); err != nil { return databaseError(err) } if smallIsBig || bigIsSmall { return ErrConflict } result, err := tx.ExecContext(ctx, `INSERT INTO creator_relation (big_account_id, small_account_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, bigAccountID, smallAccountID) if err != nil { return databaseError(err) } affected, err := result.RowsAffected() if err != nil { return err } if affected == 0 { var existingBig string if err := tx.QueryRowContext(ctx, `SELECT big_account_id FROM creator_relation WHERE small_account_id=$1`, smallAccountID).Scan(&existingBig); err != nil { return rowError(err) } if existingBig != bigAccountID { return ErrConflict } } } else { if _, err := tx.ExecContext(ctx, `DELETE FROM creator_relation WHERE big_account_id = $1 AND small_account_id = $2`, bigAccountID, smallAccountID); err != nil { return databaseError(err) } if _, err := tx.ExecContext(ctx, `UPDATE creator_strategy SET enabled=false, updated_at=now() WHERE big_account_id=$1 AND execution_account_id=$2`, bigAccountID, smallAccountID); err != nil { return databaseError(err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("commit creator relation: %w", err) } return s.InvalidateListener(ctx, bigAccountID, "账号关系变更") } func (s *Store) AccountWriteCheck(ctx context.Context, accountID string, automatic bool, action string) (AccountProfile, error) { profile, err := s.GetAccountProfile(ctx, accountID) if err != nil { return AccountProfile{}, err } return profile, CanWrite(profile, automatic, action) }